css text-shadow 9パターン — 影の値で立つ文字
css text-shadowでは、影とは光が消えた場所のはずなのに、この九つの文字は影だけで輝く — 1番は重ねた層の値で立体を立て、5番はずれた色の値で揺らし、9番はカーソルが乗った瞬間その深さの値を押し上げる。
01 text-shadow-stack-extrude-3d
02 text-shadow-emboss-engrave-dual
03 text-shadow-stroke-outline-multi
04 text-shadow-long-diagonal-retro
05 text-shadow-glitch-rgb-split
06 text-shadow-metal-sheen-layer
07 text-shadow-paper-stack-layer
08 text-shadow-scroll-depth-grow
09 text-shadow-hover-depth-lift
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 層の値が積み重なり立体になる押し出し文字
- 02 明るい値と暗い値が逆に重なり浮き出る文字
- 03 八方向の値が重なり輪郭線になる文字
- 04 対角線の長さ値だけ伸びるレトロな文字
- 05 色分離の値だけずれて壊れるグリッチ文字
- 06 層ごとに明るさの値が変わり光沢が乗る金属文字
- 07 層の値ごとに紙を一枚ずつ重ねたような文字
- 08 スクロール値の分だけ影が育つ文字
- 09 カーソルが乗ると深さの値が押し上がる文字
値ひとつがcss text-shadowをどう作り変えるか — 9パターンの並び順
以下の九つはどれも、ひとつの数値をそのままtext-shadowの文字列に書き込んでいるだけです。offset-x offset-y blur-radius colorという4つの値の並びはMDNのtext-shadowリファレンスにまとまっています。並び順にも理由があり、1番から4番までは層を重ねた立体をほぼ静止した状態で作り、5番はその安定をわざと壊し、6番と7番は色や枚数そのものを動く値にし、8番と9番はその値を読者自身のスクロールやポインターに委ねます。
こういう影の文字が実際に置かれる画面
1番の押し出しと6番の光沢はどちらも余白のあるヒーロータイトル向きです — ランディングページやプレミアムなブランドロゴタイプなど、影が100〜140pxほど伸びても本文と競合しない場所です。2番の凹凸と3番の輪郭は逆の規模で効きます — カードのバッジラベルやポスターの数字のように、浮き出て見えるか凹んで見えるか、八方向のリングが一目で読み取れる小ささが要ります。
5番のグリッチと4番のレトロな尾は、大きさより先に雰囲気を選ぶ二つです — グリッチはエラー画面の見出し、長い斜めの尾はビンテージキャンペーンの見出しで、どちらも元から寄せていないニュートラルなUIに置いても効きません。8番と9番は読者の動作そのものに従う二つで、長い紹介ページのセクション見出しがスクロール量だけ影を伸ばしたり、ポートフォリオのカードタイトルがカーソルやキーボードのフォーカスが実際に届いた時だけ持ち上がったりします。
01層の値が積み重なり立体になる押し出し文字
六層の影がそれぞれ同じ1ピクセル刻みで同じ対角線上にずれ、extrudeColor()が層番号に応じて明度を落とすので、奥の層ほど暗く沈んで見えます — perspectiveを使わず積み重ねだけで作る立体表現です。buildExtrudeShadow()は1pxから15pxの間を1秒周期のサインカーブで往復するstep値をひとつだけ読み、ランディングページのヒーロータイトルが欲しがる「静止時は薄く、動くと厚く」という表情をそのまま作ります。

<main class="stage">
<div class="ex is-demo" id="ex">
<p class="ex__badge"><span class="ex__badgeLabel">押し出し深さ</span> <span class="ex__badgeValue" id="exValue">1.0px</span></p>
<h1 class="ex__word" id="exWord">押出</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: step 값 하나로 SHADOW_LAYER_COUNT 층의 그림자를 조립한다. step 이 작을 때는
// 얇고 납작한 입체, 클 때는 층이 겹겹이 쌓여 두꺼운 입체로 보인다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-ink);
.ex {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
}
.ex__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ex__word {
margin: 0;
font-weight: 800;
font-size: clamp(64px, 22vw, 132px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
@media (max-width: 340px) {
.ex__word { font-size: clamp(48px, 24vw, 88px); }
}
@media (prefers-reduced-motion: reduce) {
.ex__word { transition: 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);
}
.ex {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.ex__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ex__word {
margin: 0;
font-weight: 800;
font-size: clamp(64px, 22vw, 132px);
line-height: 1;
white-space: nowrap;
color: #fff7e6;
will-change: text-shadow;
}
@media (max-width: 340px) {
.ex__word {
font-size: clamp(48px, 24vw, 88px);
}
}
@media (prefers-reduced-motion: reduce) {
.ex__word {
transition: none;
}
}(function () {
var SHADOW_LAYER_COUNT = 6;
var STEP_MIN_PX = 1;
var STEP_MAX_PX = 15;
var PERIOD_MS = 1000;
var word = document.getElementById('exWord');
var value = document.getElementById('exValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 레이어: 층 번호(i)가 깊어질수록 명도를 낮춰 아래로 갈수록 어두워지는 입체감을 만든다
function extrudeColor(i, n) {
var t = i / n;
var l = Math.max(6, Math.round(24 - t * 16));
return 'hsl(232 14% ' + l + '%)';
}
// 값(step) 하나로 SHADOW_LAYER_COUNT 개의 그림자를 문자열로 조립한다
function buildExtrudeShadow(step) {
var parts = [];
for (var i = 1; i <= SHADOW_LAYER_COUNT; i++) {
var d = (i * step).toFixed(2);
parts.push(d + 'px ' + d + 'px 0 ' + extrudeColor(i, SHADOW_LAYER_COUNT));
}
return parts.join(', ');
}
function renderExtrude(step) {
word.style.textShadow = buildExtrudeShadow(step);
value.textContent = step.toFixed(1) + 'px';
}
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PERIOD_MS;
var phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
renderExtrude(STEP_MIN_PX + (STEP_MAX_PX - STEP_MIN_PX) * phase);
requestAnimationFrame(loop);
}
if (reduce) {
renderExtrude(STEP_MAX_PX);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowStackExtrude3D — vanilla 판(run/412/demo/01-text-shadow-stack-extrude-3d/index.html) 의 React 포트.
// 값(step) 하나로 SHADOW_LAYER_COUNT 층의 그림자를 조립한다. step 이 작을 때는 얇은 입체,
// 클 때는 층이 겹겹이 쌓여 두꺼운 입체로 보인다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowStackExtrude3D.module.scss";
type Box<T> = { current: T };
const SHADOW_LAYER_COUNT = 6;
const STEP_MIN_PX = 1;
const STEP_MAX_PX = 15;
const PERIOD_MS = 1000;
function extrudeColor(i: number, n: number): string {
const t = i / n;
const l = Math.max(6, Math.round(24 - t * 16));
return "hsl(232 14% " + l + "%)";
}
function buildExtrudeShadow(step: number): string {
const parts: string[] = [];
for (let i = 1; i <= SHADOW_LAYER_COUNT; i++) {
const d = (i * step).toFixed(2);
parts.push(d + "px " + d + "px 0 " + extrudeColor(i, SHADOW_LAYER_COUNT));
}
return parts.join(", ");
}
export default function TextShadowStackExtrude3D() {
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(step: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildExtrudeShadow(step);
value.textContent = step.toFixed(1) + "px";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(STEP_MAX_PX);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PERIOD_MS;
const phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
render(STEP_MIN_PX + (STEP_MAX_PX - STEP_MIN_PX) * phase);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.ex, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>돌출 깊이</span> <span ref={valueRef}>1.0px</span></p>
<h1 className={styles.word} ref={wordRef}>돌출</h1>
</div>
</main>
);
}01 · 層の値が積み重なり立体になる押し出し文字 — 計測 累積面積 10.384% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// step値ひとつからSHADOW_LAYER_COUNT層分の影を文字列として組み立てる
function buildExtrudeShadow(step) {
var parts = [];
for (var i = 1; i <= SHADOW_LAYER_COUNT; i++) {
var d = (i * step).toFixed(2);
parts.push(d + 'px ' + d + 'px 0 ' + extrudeColor(i, SHADOW_LAYER_COUNT));
}
return parts.join(', ');
}
測定値: 累積面積10.4%、平均強度26.0、動いたフレーム22/23。
02明るい値と暗い値が逆に重なり浮き出る文字
angleDegという値ひとつがbuildEmbossShadow()に渡り、明るい影を一方向へ、暗い影をちょうど逆方向へ押し出します — 同じ式、符号だけが逆なので、角度が回るたびカードバッジのラベルは浮き出て見えたり凹んで見えたりを行き来します。「押された状態」を別に切り替えるスイッチはなく、角度が今どこにあるかだけで見え方が決まります。

<main class="stage">
<div class="em is-demo" id="em">
<p class="em__badge"><span class="em__badgeLabel">光の角度</span> <span class="em__badgeValue" id="emValue">0deg</span></p>
<h1 class="em__word" id="emWord">凹凸</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 각도 값(angleDeg) 하나로 밝은 그림자와 어두운 그림자를 서로 반대 방향에 배치한다.
// 각도가 돌면 빛이 도는 것처럼 도드라짐과 파임이 자리를 바꾼다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.em {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
}
.em__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.em__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
@media (max-width: 340px) {
.em__word { font-size: clamp(52px, 26vw, 92px); }
}
@media (prefers-reduced-motion: reduce) {
.em__word { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.em {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.em__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.em__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: #17141a;
will-change: text-shadow;
}
@media (max-width: 340px) {
.em__word {
font-size: clamp(52px, 26vw, 92px);
}
}
@media (prefers-reduced-motion: reduce) {
.em__word {
transition: none;
}
}(function () {
var EMBOSS_DEPTH_PX = 12.5;
var PERIOD_MS = 1000;
var word = document.getElementById('emWord');
var value = document.getElementById('emValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 값(angleDeg) 하나로 밝은 그림자·어두운 그림자를 서로 반대 방향에 배치한다
function buildEmbossShadow(angleDeg) {
var rad = (angleDeg * Math.PI) / 180;
var lx = (Math.cos(rad) * EMBOSS_DEPTH_PX).toFixed(2);
var ly = (Math.sin(rad) * EMBOSS_DEPTH_PX).toFixed(2);
var dx = (-Math.cos(rad) * EMBOSS_DEPTH_PX).toFixed(2);
var dy = (-Math.sin(rad) * EMBOSS_DEPTH_PX).toFixed(2);
return [
lx + 'px ' + ly + 'px 0 rgba(255,255,255,.85)',
dx + 'px ' + dy + 'px 1px rgba(10,10,14,.75)'
].join(', ');
}
function renderEmboss(angleDeg) {
word.style.textShadow = buildEmbossShadow(angleDeg);
value.textContent = Math.round(angleDeg % 360) + 'deg';
}
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PERIOD_MS;
var angle = (t / PERIOD_MS) * 360;
renderEmboss(angle);
requestAnimationFrame(loop);
}
if (reduce) {
renderEmboss(45);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowEmbossEngraveDual — vanilla 판(run/412/demo/02-text-shadow-emboss-engrave-dual/index.html) 의 React 포트.
// 각도 값(angleDeg) 하나로 밝은 그림자와 어두운 그림자를 서로 반대 방향에 배치한다.
// 각도가 돌면 빛이 도는 것처럼 도드라짐과 파임이 자리를 바꾼다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowEmbossEngraveDual.module.scss";
type Box<T> = { current: T };
const EMBOSS_DEPTH_PX = 12.5;
const PERIOD_MS = 1000;
function buildEmbossShadow(angleDeg: number): string {
const rad = (angleDeg * Math.PI) / 180;
const lx = (Math.cos(rad) * EMBOSS_DEPTH_PX).toFixed(2);
const ly = (Math.sin(rad) * EMBOSS_DEPTH_PX).toFixed(2);
const dx = (-Math.cos(rad) * EMBOSS_DEPTH_PX).toFixed(2);
const dy = (-Math.sin(rad) * EMBOSS_DEPTH_PX).toFixed(2);
return [
lx + "px " + ly + "px 0 rgba(255,255,255,.85)",
dx + "px " + dy + "px 1px rgba(10,10,14,.75)"
].join(", ");
}
export default function TextShadowEmbossEngraveDual() {
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(angleDeg: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildEmbossShadow(angleDeg);
value.textContent = Math.round(angleDeg % 360) + "deg";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(45);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PERIOD_MS;
const angle = (t / PERIOD_MS) * 360;
render(angle);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.em, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>빛 각도</span> <span ref={valueRef}>0deg</span></p>
<h1 className={styles.word} ref={wordRef}>요철</h1>
</div>
</main>
);
}02 · 明るい値と暗い値が逆に重なり浮き出る文字 — 計測 累積面積 10.903% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// angleDeg値ひとつで明るい影と暗い影を互いに反対方向へ配置する
function buildEmbossShadow(angleDeg) {
var rad = (angleDeg * Math.PI) / 180;
var lx = (Math.cos(rad) * EMBOSS_DEPTH_PX).toFixed(2);
var ly = (Math.sin(rad) * EMBOSS_DEPTH_PX).toFixed(2);
var dx = (-Math.cos(rad) * EMBOSS_DEPTH_PX).toFixed(2);
var dy = (-Math.sin(rad) * EMBOSS_DEPTH_PX).toFixed(2);
return [
lx + 'px ' + ly + 'px 0 rgba(255,255,255,.85)',
dx + 'px ' + dy + 'px 1px rgba(10,10,14,.75)'
].join(', ');
}
測定値: 累積面積10.9%、平均強度129.2、動いたフレーム23/23。
03八方向の値が重なり輪郭線になる文字
DIRSは上下左右の軸四方向と対角線の四方向、合わせて八方向(軸4+斜め4)を保持し、buildOutlineShadow()はひとつの距離値をその八回すべてに使って、塗りを残さず輪郭線だけをグリフの周りに残します。css text-shadow 縁取りが必要なイベントポスターの数字のように、背景が何であっても読める必要がある文字に向いています。この値を0.4pxの下限より小さくしても、八つのコピーは常に全部描かれ続けます。

<main class="stage">
<div class="ol is-demo" id="ol">
<p class="ol__badge"><span class="ol__badgeLabel">輪郭の太さ</span> <span class="ol__badgeValue" id="olValue">0.5px</span></p>
<h1 class="ol__word" id="olWord">輪郭</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 값 d 하나를 여덟 방향(상하좌우+대각선)에 같은 거리만큼 겹쳐, 글자 채움 없이도
// 둘레에 윤곽선만 남는다. d 가 커질수록 윤곽선이 두꺼워진다.
$duration: $dur-loop;
$easing: linear;
$color: $stage-orange;
@include stage($stage-orange);
.ol {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
}
.ol__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ol__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
@media (max-width: 340px) {
.ol__word { font-size: clamp(52px, 26vw, 92px); }
}
@media (prefers-reduced-motion: reduce) {
.ol__word { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ol {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.ol__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ol__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: #ff4d1f;
will-change: text-shadow;
}
@media (max-width: 340px) {
.ol__word {
font-size: clamp(52px, 26vw, 92px);
}
}
@media (prefers-reduced-motion: reduce) {
.ol__word {
transition: none;
}
}(function () {
var OUTLINE_MAX_PX = 15;
var PERIOD_MS = 1000;
var DIRS = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[0.7071, 0.7071], [-0.7071, 0.7071], [0.7071, -0.7071], [-0.7071, -0.7071]
];
var word = document.getElementById('olWord');
var value = document.getElementById('olValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 값(d) 하나를 여덟 방향에 같은 거리만큼 겹쳐 채움 없이도 윤곽선만 남긴다
function buildOutlineShadow(d) {
var parts = [];
for (var i = 0; i < DIRS.length; i++) {
var x = (DIRS[i][0] * d).toFixed(2);
var y = (DIRS[i][1] * d).toFixed(2);
parts.push(x + 'px ' + y + 'px 0 rgba(23,20,26,.92)');
}
return parts.join(', ');
}
function renderOutline(d) {
word.style.textShadow = buildOutlineShadow(d);
value.textContent = d.toFixed(1) + 'px';
}
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PERIOD_MS;
var phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
renderOutline(0.4 + (OUTLINE_MAX_PX - 0.4) * phase);
requestAnimationFrame(loop);
}
if (reduce) {
renderOutline(OUTLINE_MAX_PX);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowStrokeOutlineMulti — vanilla 판(run/412/demo/03-text-shadow-stroke-outline-multi/index.html) 의 React 포트.
// 값 d 하나를 여덟 방향(상하좌우+대각선)에 같은 거리만큼 겹쳐, 글자 채움 없이도
// 둘레에 윤곽선만 남는다. d 가 커질수록 윤곽선이 두꺼워진다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowStrokeOutlineMulti.module.scss";
type Box<T> = { current: T };
const OUTLINE_MAX_PX = 15;
const PERIOD_MS = 1000;
const DIRS: [number, number][] = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[0.7071, 0.7071], [-0.7071, 0.7071], [0.7071, -0.7071], [-0.7071, -0.7071]
];
function buildOutlineShadow(d: number): string {
const parts: string[] = [];
for (let i = 0; i < DIRS.length; i++) {
const x = (DIRS[i][0] * d).toFixed(2);
const y = (DIRS[i][1] * d).toFixed(2);
parts.push(x + "px " + y + "px 0 rgba(23,20,26,.92)");
}
return parts.join(", ");
}
export default function TextShadowStrokeOutlineMulti() {
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(d: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildOutlineShadow(d);
value.textContent = d.toFixed(1) + "px";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(OUTLINE_MAX_PX);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PERIOD_MS;
const phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
render(0.4 + (OUTLINE_MAX_PX - 0.4) * phase);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.ol, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>윤곽 두께</span> <span ref={valueRef}>0.5px</span></p>
<h1 className={styles.word} ref={wordRef}>윤곽</h1>
</div>
</main>
);
}03 · 八方向の値が重なり輪郭線になる文字 — 計測 累積面積 10.6% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var DIRS = [
[1, 0], [-1, 0], [0, 1], [0, -1],
[0.7071, 0.7071], [-0.7071, 0.7071], [0.7071, -0.7071], [-0.7071, -0.7071]
];
var word = document.getElementById('olWord');
var value = document.getElementById('olValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 距離値dひとつを八方向に重ね、塗りがなくても輪郭線だけを残す
function buildOutlineShadow(d) {
var parts = [];
for (var i = 0; i < DIRS.length; i++) {
var x = (DIRS[i][0] * d).toFixed(2);
var y = (DIRS[i][1] * d).toFixed(2);
parts.push(x + 'px ' + y + 'px 0 rgba(23,20,26,.92)');
}
return parts.join(', ');
}
測定値: 累積面積10.6%、平均強度140.2、動いたフレーム21/23。
04斜め方向の長さ値だけ長く伸びるレトロ文字
buildRetroShadow()は影をひとつずらすのではなく、lenの値だけ1px刻みの対角線の層を増やしていきます — ビンテージキャンペーンの見出しが必要とするだけの段数が積み上がり、後ろの層ほど少しずつ透明になります。ループの低い側では4段しかなく、高い側の34では全段が揃う、という差が短い一押しと長く引きずる尾の違いを生みます。

<main class="stage">
<div class="rt is-demo" id="rt">
<p class="rt__badge"><span class="rt__badgeLabel">影の長さ</span> <span class="rt__badgeValue" id="rtValue">4px</span></p>
<h1 class="rt__word" id="rtWord">レトロ</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 길이 값(len) 하나를 대각선 방향으로 1px 씩 반복해 그 값만큼 길게 늘어뜨린다.
// 복고 포스터에서 보던 긴 그림자 글자 — 값이 커질수록 그림자가 길어진다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-paper);
.rt {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
padding-bottom: $sp-8;
}
.rt__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.rt__word {
margin: 0;
font-weight: 800;
font-size: clamp(56px, 18vw, 104px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
@media (max-width: 340px) {
.rt__word { font-size: clamp(40px, 20vw, 68px); }
}
@media (prefers-reduced-motion: reduce) {
.rt__word { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rt {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
padding-bottom: 32px;
}
.rt__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.rt__word {
margin: 0;
font-weight: 800;
font-size: clamp(56px, 18vw, 104px);
line-height: 1;
white-space: nowrap;
color: #17141a;
will-change: text-shadow;
}
@media (max-width: 340px) {
.rt__word {
font-size: clamp(40px, 20vw, 68px);
}
}
@media (prefers-reduced-motion: reduce) {
.rt__word {
transition: none;
}
}(function () {
var RETRO_MAX_LEN = 34;
var RETRO_MIN_LEN = 4;
var PERIOD_MS = 1000;
var word = document.getElementById('rtWord');
var value = document.getElementById('rtValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 값(len) 하나를 대각선 방향으로 한 칸(1px)씩 반복해 길이만큼 길게 늘어뜨린다
function buildRetroShadow(len) {
var parts = [];
var n = Math.round(len);
for (var i = 1; i <= n; i++) {
parts.push(i + 'px ' + i + 'px 0 rgba(10,79,232,' + (0.9 - (i / n) * 0.35).toFixed(2) + ')');
}
return parts.join(', ');
}
function renderRetro(len) {
word.style.textShadow = buildRetroShadow(len);
value.textContent = Math.round(len) + 'px';
}
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PERIOD_MS;
var phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
renderRetro(RETRO_MIN_LEN + (RETRO_MAX_LEN - RETRO_MIN_LEN) * phase);
requestAnimationFrame(loop);
}
if (reduce) {
renderRetro(RETRO_MAX_LEN);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowLongDiagonalRetro — vanilla 판(run/412/demo/04-text-shadow-long-diagonal-retro/index.html) 의 React 포트.
// 길이 값(len) 하나를 대각선 방향으로 1px 씩 반복해 그 값만큼 길게 늘어뜨린다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowLongDiagonalRetro.module.scss";
type Box<T> = { current: T };
const RETRO_MAX_LEN = 34;
const RETRO_MIN_LEN = 4;
const PERIOD_MS = 1000;
function buildRetroShadow(len: number): string {
const parts: string[] = [];
const n = Math.round(len);
for (let i = 1; i <= n; i++) {
parts.push(i + "px " + i + "px 0 rgba(10,79,232," + (0.9 - (i / n) * 0.35).toFixed(2) + ")");
}
return parts.join(", ");
}
export default function TextShadowLongDiagonalRetro() {
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(len: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildRetroShadow(len);
value.textContent = Math.round(len) + "px";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(RETRO_MAX_LEN);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PERIOD_MS;
const phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
render(RETRO_MIN_LEN + (RETRO_MAX_LEN - RETRO_MIN_LEN) * phase);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.rt, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>그림자 길이</span> <span ref={valueRef}>4px</span></p>
<h1 className={styles.word} ref={wordRef}>레트로</h1>
</div>
</main>
);
}04 · 対角線の長さ値だけ伸びるレトロな文字 — 計測 累積面積 10.081% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// len値ひとつを対角線方向へ1pxずつ繰り返し、その長さ分だけ伸ばす
function buildRetroShadow(len) {
var parts = [];
var n = Math.round(len);
for (var i = 1; i <= n; i++) {
parts.push(i + 'px ' + i + 'px 0 rgba(10,79,232,' + (0.9 - (i / n) * 0.35).toFixed(2) + ')');
}
return parts.join(', ');
}
測定値: 累積面積10.1%、平均強度140.3、動いたフレーム22/23。
05色分離値の分だけずれて崩れるグリッチ文字
seedRand()はMath.random()ではなく固定シードの生成器で、同じ11というシードから同じ数列が毎回再生されます — 見た目はランダムでも、buildGlitchShadow()が作る揺れは実は決まった手順です。クリックすると新しいシードで作り直され、magが基準値の14倍まで220ms跳ね上がります — この9つの中で唯一、ループだけでなく実際のクリックリスナーを持つ項目です。

<main class="stage">
<div class="gl is-demo" id="gl">
<p class="gl__badge"><span class="gl__badgeLabel">分離距離</span> <span class="gl__badgeValue" id="glValue">0.0px</span></p>
<button type="button" class="gl__word" id="glWord">グリッチ</button>
<p class="gl__hint">クリックするとまた崩れる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 값(mag) 하나를 고정 시드 PRNG 로 흔들어 빨강·파랑 그림자를 반대 방향으로 벌린다.
// mag 가 커질수록 화면이 어긋난 신호처럼 거칠게 깨진다. 클릭하면 시드를 새로 돌려 다시 깨진다.
$duration: $dur-instant;
$easing: linear;
$color: $subject-cream;
@include stage($stage-ink);
.gl {
display: grid;
gap: $sp-3;
justify-items: center;
text-align: center;
}
.gl__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.gl__word {
margin: 0;
border: 0;
background: none;
padding: 0;
cursor: pointer;
font-weight: 800;
font-size: clamp(60px, 20vw, 116px);
line-height: 1;
white-space: nowrap;
color: $color;
font-family: inherit;
will-change: text-shadow;
}
.gl__word:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 6px;
}
.gl__hint {
margin: 0;
color: rgba(255, 247, 230, .6);
font-size: 11px;
}
@media (max-width: 340px) {
.gl__word { font-size: clamp(44px, 22vw, 76px); }
}
@media (prefers-reduced-motion: reduce) {
.gl__word { transition: 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);
}
.gl {
display: grid;
gap: 12px;
justify-items: center;
text-align: center;
}
.gl__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.gl__word {
margin: 0;
border: 0;
background: none;
padding: 0;
cursor: pointer;
font-weight: 800;
font-size: clamp(60px, 20vw, 116px);
line-height: 1;
white-space: nowrap;
color: #fff7e6;
font-family: inherit;
will-change: text-shadow;
}
.gl__word:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 6px;
}
.gl__hint {
margin: 0;
color: rgba(255, 247, 230, 0.6);
font-size: 11px;
}
@media (max-width: 340px) {
.gl__word {
font-size: clamp(44px, 22vw, 76px);
}
}
@media (prefers-reduced-motion: reduce) {
.gl__word {
transition: none;
}
}(function () {
var GLITCH_SPLIT_PX = 2;
var BURST_PX = GLITCH_SPLIT_PX * 14;
var PULSE_MS = 480;
var word = document.getElementById('glWord');
var value = document.getElementById('glValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var seed = 11;
// 고정 시드 PRNG — Math.random 대신 프레임마다 같은 규칙으로 다음 값을 만든다
function seedRand(s) {
return function () {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
var rnd = seedRand(seed);
// 값(mag) 하나를 시드 노이즈로 흔들어 빨강·파랑 그림자를 반대 방향으로 어긋나게 한다
function buildGlitchShadow(mag) {
var rx = (mag * (rnd() * 2 - 1)).toFixed(2);
var ry = (mag * 0.28 * (rnd() * 2 - 1)).toFixed(2);
var bx = (-mag * (rnd() * 2 - 1)).toFixed(2);
var by = (-mag * 0.28 * (rnd() * 2 - 1)).toFixed(2);
return [
rx + 'px ' + ry + 'px 0 rgba(255,54,96,.88)',
bx + 'px ' + by + 'px 0 rgba(58,164,255,.88)'
].join(', ');
}
function renderGlitch(mag) {
word.style.textShadow = buildGlitchShadow(mag);
value.textContent = mag.toFixed(1) + 'px';
}
var burstUntil = 0;
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PULSE_MS;
var tri = Math.abs((t / PULSE_MS) * 2 - 1);
var mag = GLITCH_SPLIT_PX * (0.3 + tri * 9.5);
if (now < burstUntil) mag = BURST_PX;
renderGlitch(mag);
requestAnimationFrame(loop);
}
word.addEventListener('click', function () {
seed = (seed * 977 + 313) & 0x7fffffff;
rnd = seedRand(seed || 1);
burstUntil = performance.now() + 220;
if (reduce) renderGlitch(BURST_PX);
});
if (reduce) {
renderGlitch(GLITCH_SPLIT_PX);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowGlitchRgbSplit — vanilla 판(run/412/demo/05-text-shadow-glitch-rgb-split/index.html) 의 React 포트.
// 값(mag) 하나를 고정 시드 PRNG 로 흔들어 빨강·파랑 그림자를 반대 방향으로 벌린다.
// 클릭하면 시드를 새로 돌려 다시 깨진다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowGlitchRgbSplit.module.scss";
type Box<T> = { current: T };
const GLITCH_SPLIT_PX = 2;
const BURST_PX = GLITCH_SPLIT_PX * 14;
const PULSE_MS = 480;
function seedRand(s: number): () => number {
let state = s;
return function () {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
}
function buildGlitchShadow(mag: number, rnd: () => number): string {
const rx = (mag * (rnd() * 2 - 1)).toFixed(2);
const ry = (mag * 0.28 * (rnd() * 2 - 1)).toFixed(2);
const bx = (-mag * (rnd() * 2 - 1)).toFixed(2);
const by = (-mag * 0.28 * (rnd() * 2 - 1)).toFixed(2);
return [
rx + "px " + ry + "px 0 rgba(255,54,96,.88)",
bx + "px " + by + "px 0 rgba(58,164,255,.88)"
].join(", ");
}
export default function TextShadowGlitchRgbSplit() {
const wordRef = useRef<HTMLButtonElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const seedRef = useRef<number>(11) as Box<number>;
const rndRef = useRef<() => number>(seedRand(11)) as Box<() => number>;
const burstUntilRef = useRef<number>(0) as Box<number>;
const [reduce, setReduce] = useState(false);
function render(mag: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildGlitchShadow(mag, rndRef.current);
value.textContent = mag.toFixed(1) + "px";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(GLITCH_SPLIT_PX);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PULSE_MS;
const tri = Math.abs((t / PULSE_MS) * 2 - 1);
let mag = GLITCH_SPLIT_PX * (0.3 + tri * 9.5);
if (now < burstUntilRef.current) mag = BURST_PX;
render(mag);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onClick(): void {
seedRef.current = (seedRef.current * 977 + 313) & 0x7fffffff;
rndRef.current = seedRand(seedRef.current || 1);
burstUntilRef.current = performance.now() + 220;
if (reduce) render(BURST_PX);
}
return (
<main className={styles.stage}>
<div className={[styles.gl, styles.isDemo].join(" ")}>
<p className={styles.badge}><span>분리 거리</span> <span ref={valueRef}>0.0px</span></p>
<button type="button" className={styles.word} ref={wordRef} onClick={onClick}>글리치</button>
<p className={styles.hint}>클릭하면 다시 깨진다</p>
</div>
</main>
);
}05 · 色分離の値だけずれて壊れるグリッチ文字 — 計測 累積面積 10.158% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// mag値ひとつをシード付きノイズで揺らし、赤い影と青い影を反対方向へずらす
function buildGlitchShadow(mag) {
var rx = (mag * (rnd() * 2 - 1)).toFixed(2);
var ry = (mag * 0.28 * (rnd() * 2 - 1)).toFixed(2);
var bx = (-mag * (rnd() * 2 - 1)).toFixed(2);
var by = (-mag * 0.28 * (rnd() * 2 - 1)).toFixed(2);
return [
rx + 'px ' + ry + 'px 0 rgba(255,54,96,.88)',
bx + 'px ' + by + 'px 0 rgba(58,164,255,.88)'
].join(', ');
}
測定値: 累積面積10.2%、平均強度144.0、動いたフレーム23/23。
06明るさ値が層ごとに異なり光沢がまわる金属文字
buildSheenShadow()は七つの層それぞれを少しずつ位相のずれたサインカーブに通し、角度の値を動かすとハイライトが一点で動くのではなく、層を伝って上下に流れる帯として見えます。「金属」という言葉はプレミアムなブランドロゴタイプが求める見え方を指しているだけで、特定の素材名や商標は使っていません。

<main class="stage">
<div class="mt is-demo" id="mt">
<p class="mt__badge"><span class="mt__badgeLabel">光沢角度</span> <span class="mt__badgeValue" id="mtValue">0deg</span></p>
<h1 class="mt__word" id="mtWord">金属</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 각도 값 하나로 SHEEN_LAYER_COUNT 층마다 밝기를 사인 곡선을 따라 다르게 겹친다.
// 각도가 돌면 밝은 띠가 층을 타고 흘러 금속 표면에 빛이 지나가는 것처럼 보인다.
// 상표명 없이 「금속 광택」이라고만 부른다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.mt {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
}
.mt__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.mt__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
@media (max-width: 340px) {
.mt__word { font-size: clamp(52px, 26vw, 92px); }
}
@media (prefers-reduced-motion: reduce) {
.mt__word { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mt {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.mt__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.mt__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: #17141a;
will-change: text-shadow;
}
@media (max-width: 340px) {
.mt__word {
font-size: clamp(52px, 26vw, 92px);
}
}
@media (prefers-reduced-motion: reduce) {
.mt__word {
transition: none;
}
}(function () {
var SHEEN_LAYER_COUNT = 7;
var PERIOD_MS = 900;
var word = document.getElementById('mtWord');
var value = document.getElementById('mtValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 각도 값 하나로 층마다 밝기를 사인 곡선을 따라 다르게 겹쳐, 위·아래가 서로 다르게 빛나는
// 金属 광택을 만든다. 층 위상(phase)이 갈릴수록 밝은 띠가 층을 타고 흐른다
function buildSheenShadow(angleDeg) {
var rad = (angleDeg * Math.PI) / 180;
var parts = [];
for (var i = 1; i <= SHEEN_LAYER_COUNT; i++) {
var phase = rad + (i / SHEEN_LAYER_COUNT) * Math.PI * 1.4;
var bright = (Math.sin(phase) + 1) / 2;
var l = Math.round(22 + bright * 58);
var y = (i * 6.8).toFixed(2);
parts.push('0px ' + y + 'px 0 hsl(220 10% ' + l + '%)');
}
return parts.join(', ');
}
function renderSheen(angleDeg) {
word.style.textShadow = buildSheenShadow(angleDeg);
value.textContent = Math.round(angleDeg % 360) + 'deg';
}
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PERIOD_MS;
var angle = (t / PERIOD_MS) * 360;
renderSheen(angle);
requestAnimationFrame(loop);
}
if (reduce) {
renderSheen(90);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowMetalSheenLayer — vanilla 판(run/412/demo/06-text-shadow-metal-sheen-layer/index.html) 의 React 포트.
// 각도 값 하나로 SHEEN_LAYER_COUNT 층마다 밝기를 사인 곡선을 따라 다르게 겹친다.
// 각도가 돌면 밝은 띠가 층을 타고 흘러 금속 표면에 빛이 지나가는 것처럼 보인다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowMetalSheenLayer.module.scss";
type Box<T> = { current: T };
const SHEEN_LAYER_COUNT = 7;
const PERIOD_MS = 900;
function buildSheenShadow(angleDeg: number): string {
const rad = (angleDeg * Math.PI) / 180;
const parts: string[] = [];
for (let i = 1; i <= SHEEN_LAYER_COUNT; i++) {
const phase = rad + (i / SHEEN_LAYER_COUNT) * Math.PI * 1.4;
const bright = (Math.sin(phase) + 1) / 2;
const l = Math.round(22 + bright * 58);
const y = (i * 6.8).toFixed(2);
parts.push("0px " + y + "px 0 hsl(220 10% " + l + "%)");
}
return parts.join(", ");
}
export default function TextShadowMetalSheenLayer() {
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(angleDeg: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildSheenShadow(angleDeg);
value.textContent = Math.round(angleDeg % 360) + "deg";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(90);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PERIOD_MS;
const angle = (t / PERIOD_MS) * 360;
render(angle);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.mt, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>광택 각도</span> <span ref={valueRef}>0deg</span></p>
<h1 className={styles.word} ref={wordRef}>금속</h1>
</div>
</main>
);
}06 · 層ごとに明るさの値が変わり光沢が乗る金属文字 — 計測 累積面積 10.386% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 角度値ひとつで層ごとの明るさをサイン曲線に沿って変え、上と下で光り方を変える
// 金属光沢を作る。位相がずれるほど明るい帯が層を伝って流れる
function buildSheenShadow(angleDeg) {
var rad = (angleDeg * Math.PI) / 180;
var parts = [];
for (var i = 1; i <= SHEEN_LAYER_COUNT; i++) {
var phase = rad + (i / SHEEN_LAYER_COUNT) * Math.PI * 1.4;
var bright = (Math.sin(phase) + 1) / 2;
var l = Math.round(22 + bright * 58);
var y = (i * 6.8).toFixed(2);
parts.push('0px ' + y + 'px 0 hsl(220 10% ' + l + '%)');
}
return parts.join(', ');
}
測定値: 累積面積10.4%、平均強度43.9、動いたフレーム23/23。
07層の値ごとに紙が一枚ずつ重なったような文字
buildPaperShadow()のループは一段ごとに影を二枚ずつ積みます — フラットな紙の色と、その下のやわらかい影の縁 — 層の偶奇で暖色と寒色の紙色を交互に切り替えるので、カードニュースのタイトルは最大六枚のばらけた紙のように見えます。動いているのは層の数そのもので、1なら一枚、PAPER_LAYER_COUNT(6)なら全段です。

<main class="stage">
<div class="pp is-demo" id="pp">
<p class="pp__badge"><span class="pp__badgeLabel">紙の層</span> <span class="pp__badgeValue" id="ppValue">1</span></p>
<h1 class="pp__word" id="ppWord">紙</h1>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 층 값(n) 하나로 색·거리 값이 다른 종이 층을 n 장 쌓는다. n 이 늘수록 종이를
// 여러 장 겹쳐 놓은 것처럼 보이고, 줄면 한두 장만 남은 듯 보인다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-orange);
.pp {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
}
.pp__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pp__word {
margin: 0;
font-weight: 800;
font-size: clamp(68px, 22vw, 128px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
@media (max-width: 340px) {
.pp__word { font-size: clamp(48px, 24vw, 84px); }
}
@media (prefers-reduced-motion: reduce) {
.pp__word { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pp {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.pp__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pp__word {
margin: 0;
font-weight: 800;
font-size: clamp(68px, 22vw, 128px);
line-height: 1;
white-space: nowrap;
color: #17141a;
will-change: text-shadow;
}
@media (max-width: 340px) {
.pp__word {
font-size: clamp(48px, 24vw, 84px);
}
}
@media (prefers-reduced-motion: reduce) {
.pp__word {
transition: none;
}
}(function () {
var PAPER_LAYER_COUNT = 6;
var OFFSET_STEP_PX = 12;
var PERIOD_MS = 1000;
var word = document.getElementById('ppWord');
var value = document.getElementById('ppValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 층 값(n) 하나로 색·거리 값이 다른 紙 층을 n 장 쌓는다 — n 이 늘수록 종이가
// 여러 장 겹친 것처럼 보인다
function buildPaperShadow(n) {
var parts = [];
for (var i = 1; i <= n; i++) {
var d = (i * OFFSET_STEP_PX).toFixed(2);
var warm = i % 2 === 0;
var sheet = warm ? 'rgba(255,247,230,.92)' : 'rgba(246,238,222,.92)';
parts.push(d + 'px ' + d + 'px 0 ' + sheet);
parts.push(d + 'px ' + (Number(d) + 1.4).toFixed(2) + 'px 2px rgba(23,20,26,' + (0.22 - i * 0.02).toFixed(2) + ')');
}
return parts.join(', ');
}
function renderPaper(n) {
word.style.textShadow = buildPaperShadow(n);
value.textContent = String(n);
}
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) % PERIOD_MS;
var phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
var n = 1 + Math.round(phase * (PAPER_LAYER_COUNT - 1));
renderPaper(n);
requestAnimationFrame(loop);
}
if (reduce) {
renderPaper(PAPER_LAYER_COUNT);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowPaperStackLayer — vanilla 판(run/412/demo/07-text-shadow-paper-stack-layer/index.html) 의 React 포트.
// 층 값(n) 하나로 색·거리 값이 다른 종이 층을 n 장 쌓는다 — n 이 늘수록 종이가
// 여러 장 겹친 것처럼 보인다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowPaperStackLayer.module.scss";
type Box<T> = { current: T };
const PAPER_LAYER_COUNT = 6;
const OFFSET_STEP_PX = 12;
const PERIOD_MS = 1000;
function buildPaperShadow(n: number): string {
const parts: string[] = [];
for (let i = 1; i <= n; i++) {
const d = (i * OFFSET_STEP_PX).toFixed(2);
const warm = i % 2 === 0;
const sheet = warm ? "rgba(255,247,230,.92)" : "rgba(246,238,222,.92)";
parts.push(d + "px " + d + "px 0 " + sheet);
parts.push(d + "px " + (Number(d) + 1.4).toFixed(2) + "px 2px rgba(23,20,26," + (0.22 - i * 0.02).toFixed(2) + ")");
}
return parts.join(", ");
}
export default function TextShadowPaperStackLayer() {
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(n: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildPaperShadow(n);
value.textContent = String(n);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(PAPER_LAYER_COUNT);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) % PERIOD_MS;
const phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
const n = 1 + Math.round(phase * (PAPER_LAYER_COUNT - 1));
render(n);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.pp, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>종이 층</span> <span ref={valueRef}>1</span></p>
<h1 className={styles.word} ref={wordRef}>종이</h1>
</div>
</main>
);
}07 · 層の値ごとに紙を一枚ずつ重ねたような文字 — 計測 累積面積 11.642% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 層値nひとつから色と距離が異なる紙の層をn枚重ねる —
// 枚数が増えるほど紙を何枚も重ねたように見える
function buildPaperShadow(n) {
var parts = [];
for (var i = 1; i <= n; i++) {
var d = (i * OFFSET_STEP_PX).toFixed(2);
var warm = i % 2 === 0;
var sheet = warm ? 'rgba(255,247,230,.92)' : 'rgba(246,238,222,.92)';
parts.push(d + 'px ' + d + 'px 0 ' + sheet);
parts.push(d + 'px ' + (Number(d) + 1.4).toFixed(2) + 'px 2px rgba(23,20,26,' + (0.22 - i * 0.02).toFixed(2) + ')');
}
return parts.join(', ');
}
測定値: 累積面積11.6%、平均強度145.0、動いたフレーム18/23。
08スクロール値の分だけ影が育つ文字
buildScrollShadow()はタイマーでは動きません — ratioFromScroll()がビューポート自身のscrollTopをscrollHeightと比べて実際のscrollイベントから読むので、長い紹介ページのセクション見出しは読者が実際にスクロールした分だけ影を伸ばします。本物のwheel・touchstart・keydownが起きるまでは、台本通りのループが同じ比率を演じてGIFが0%のまま止まって見えないようにし、実際のスクロールが一度でも起きればそちらに完全に置き換わります。

<main class="stage">
<div class="sc is-demo" id="sc">
<p class="sc__badge"><span class="sc__badgeLabel">スクロール進行</span> <span class="sc__badgeValue" id="scValue">0%</span></p>
<div class="sc__viewport" id="scViewport" tabindex="0">
<div class="sc__track" id="scTrack">
<h1 class="sc__word" id="scWord">深さ</h1>
<div class="sc__spacer" aria-hidden="true"></div>
</div>
</div>
<p class="sc__hint">ホイールか方向キーで下へ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 스크롤 진행 값(ratio 0~1) 하나를 그림자 거리 값에 그대로 이어 붙인다.
// 컨테이너를 내릴수록 진행 값이 커지고 그림자가 그만큼 자란다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-paper);
.sc {
display: grid;
gap: $sp-3;
justify-items: center;
text-align: center;
width: 100%;
}
.sc__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sc__viewport {
width: min(100%, 320px);
height: 150px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .04);
scrollbar-width: thin;
}
.sc__viewport:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
.sc__track {
display: grid;
justify-items: center;
padding-top: $sp-6;
}
.sc__word {
position: sticky;
top: $sp-4;
margin: 0;
font-weight: 800;
font-size: clamp(48px, 16vw, 80px);
line-height: 1;
white-space: nowrap;
color: $color;
will-change: text-shadow;
}
.sc__spacer {
height: 260px;
width: 1px;
}
.sc__hint {
margin: 0;
color: rgba(23, 20, 26, .55);
font-size: 11px;
}
@media (max-width: 340px) {
.sc { gap: $sp-1; }
.sc__word { font-size: clamp(32px, 18vw, 48px); }
.sc__viewport { height: 84px; }
.sc__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc__word { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
display: grid;
gap: 12px;
justify-items: center;
text-align: center;
width: 100%;
}
.sc__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sc__viewport {
width: min(100%, 320px);
height: 150px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
scrollbar-width: thin;
}
.sc__viewport:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.sc__track {
display: grid;
justify-items: center;
padding-top: 24px;
}
.sc__word {
position: sticky;
top: 16px;
margin: 0;
font-weight: 800;
font-size: clamp(48px, 16vw, 80px);
line-height: 1;
white-space: nowrap;
color: #17141a;
will-change: text-shadow;
}
.sc__spacer {
height: 260px;
width: 1px;
}
.sc__hint {
margin: 0;
color: rgba(23, 20, 26, 0.55);
font-size: 11px;
}
@media (max-width: 340px) {
.sc {
gap: 4px;
}
.sc__word {
font-size: clamp(32px, 18vw, 48px);
}
.sc__viewport {
height: 84px;
}
.sc__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc__word {
transition: none;
}
}(function () {
var SCROLL_MAX_PX = 26;
var PERIOD_MS = 1000;
var viewport = document.getElementById('scViewport');
var word = document.getElementById('scWord');
var value = document.getElementById('scValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var hasUser = false;
// スクロール進行 값(ratio 0~1) 하나를 그림자 거리 값에 그대로 이어, 내려갈수록 그림자가 자란다
function buildScrollShadow(ratio) {
var d = (ratio * SCROLL_MAX_PX).toFixed(2);
return [
(d * 0.4).toFixed(2) + 'px ' + d + 'px 0 rgba(10,79,232,.5)',
(d * 0.4).toFixed(2) + 'px ' + (d * 2).toFixed(2) + 'px ' + (4 + ratio * 10).toFixed(1) + 'px rgba(10,79,232,' + (0.28 + ratio * 0.3).toFixed(2) + ')'
].join(', ');
}
function renderScroll(ratio) {
word.style.textShadow = buildScrollShadow(ratio);
value.textContent = Math.round(ratio * 100) + '%';
}
function ratioFromScroll() {
var max = viewport.scrollHeight - viewport.clientHeight;
return max > 0 ? Math.max(0, Math.min(1, viewport.scrollTop / max)) : 0;
}
viewport.addEventListener('scroll', function () {
renderScroll(ratioFromScroll());
});
['wheel', 'touchstart', 'keydown'].forEach(function (ev) {
viewport.addEventListener(ev, function () { hasUser = true; }, { passive: true });
});
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
if (!hasUser) {
var t = (now - startTime) % PERIOD_MS;
var phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
var max = viewport.scrollHeight - viewport.clientHeight;
viewport.scrollTop = phase * max;
}
requestAnimationFrame(loop);
}
renderScroll(0);
if (!reduce) requestAnimationFrame(loop);
})();// TextShadowScrollDepthGrow — vanilla 판(run/412/demo/08-text-shadow-scroll-depth-grow/index.html) 의 React 포트.
// 스크롤 진행 값(ratio 0~1) 하나를 그림자 거리 값에 그대로 이어, 내려갈수록 그림자가 자란다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowScrollDepthGrow.module.scss";
type Box<T> = { current: T };
const SCROLL_MAX_PX = 26;
const PERIOD_MS = 1000;
function buildScrollShadow(ratio: number): string {
const d = (ratio * SCROLL_MAX_PX).toFixed(2);
return [
(Number(d) * 0.4).toFixed(2) + "px " + d + "px 0 rgba(10,79,232,.5)",
(Number(d) * 0.4).toFixed(2) + "px " + (Number(d) * 2).toFixed(2) + "px " + (4 + ratio * 10).toFixed(1) + "px rgba(10,79,232," + (0.28 + ratio * 0.3).toFixed(2) + ")"
].join(", ");
}
export default function TextShadowScrollDepthGrow() {
const viewportRef = useRef<HTMLDivElement | null>(null);
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const hasUserRef = useRef<boolean>(false) as Box<boolean>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(ratio: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildScrollShadow(ratio);
value.textContent = Math.round(ratio * 100) + "%";
}
function ratioFromScroll(): number {
const viewport = viewportRef.current;
if (!viewport) return 0;
const max = viewport.scrollHeight - viewport.clientHeight;
return max > 0 ? Math.max(0, Math.min(1, viewport.scrollTop / max)) : 0;
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
render(0);
}, []);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
function onScroll(): void {
render(ratioFromScroll());
}
function onUser(): void {
hasUserRef.current = true;
}
viewport.addEventListener("scroll", onScroll);
viewport.addEventListener("wheel", onUser, { passive: true });
viewport.addEventListener("touchstart", onUser, { passive: true });
viewport.addEventListener("keydown", onUser);
return () => {
viewport.removeEventListener("scroll", onScroll);
viewport.removeEventListener("wheel", onUser);
viewport.removeEventListener("touchstart", onUser);
viewport.removeEventListener("keydown", onUser);
};
}, []);
useEffect(() => {
if (reduce) return;
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const viewport = viewportRef.current;
if (viewport && !hasUserRef.current) {
const t = (now - t0Ref.current) % PERIOD_MS;
const phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
const max = viewport.scrollHeight - viewport.clientHeight;
viewport.scrollTop = phase * max;
}
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
const cls = [styles.sc, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>스크롤 진행</span> <span ref={valueRef}>0%</span></p>
<div className={styles.viewport} ref={viewportRef} tabIndex={0}>
<div className={styles.track}>
<h1 className={styles.word} ref={wordRef}>깊이</h1>
<div className={styles.spacer} aria-hidden="true" />
</div>
</div>
<p className={styles.hint}>휠·방향키로 내려 본다</p>
</div>
</main>
);
}08 · スクロール値の分だけ影が育つ文字 — 計測 累積面積 9.894% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// スクロール進行値(ratio 0〜1)ひとつをそのまま影の距離値に渡し、下るほど影が育つ
function buildScrollShadow(ratio) {
var d = (ratio * SCROLL_MAX_PX).toFixed(2);
return [
(d * 0.4).toFixed(2) + 'px ' + d + 'px 0 rgba(10,79,232,.5)',
(d * 0.4).toFixed(2) + 'px ' + (d * 2).toFixed(2) + 'px ' + (4 + ratio * 10).toFixed(1) + 'px rgba(10,79,232,' + (0.28 + ratio * 0.3).toFixed(2) + ')'
].join(', ');
}
function renderScroll(ratio) {
word.style.textShadow = buildScrollShadow(ratio);
value.textContent = Math.round(ratio * 100) + '%';
}
function ratioFromScroll() {
var max = viewport.scrollHeight - viewport.clientHeight;
return max > 0 ? Math.max(0, Math.min(1, viewport.scrollTop / max)) : 0;
}
viewport.addEventListener('scroll', function () {
renderScroll(ratioFromScroll());
});
['wheel', 'touchstart', 'keydown'].forEach(function (ev) {
viewport.addEventListener(ev, function () { hasUser = true; }, { passive: true });
});
測定値: 累積面積9.9%、平均強度66.5、動いたフレーム23/23。
09カーソルが乗ると深さの値が押し上がる文字
buildLiftShadow()は0〜1の近接度値からオフセットとぼかしの両方を同時に押し上げ、curP += (targetP - curP) * LERPという別の行がその値自体をなめらかにならすので、ポートフォリオのプロジェクトカードタイトルはポインターが乗った瞬間に満杯の深さへ飛びつきません。フォーカスとホバーは同じtargetPを動かすので、キーボードでタブ移動してもマウスと同じだけ持ち上がります。

<main class="stage">
<div class="hv is-demo" id="hv">
<p class="hv__badge"><span class="hv__badgeLabel">浮き上がり値</span> <span class="hv__badgeValue" id="hvValue">0.0px</span></p>
<div class="hv__scene" id="hvScene">
<h1 class="hv__word" id="hvWord" tabindex="0">浮上</h1>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 근접도 값(p 0~1) 하나로 그림자 오프셋·번짐 값을 함께 밀어 올린다.
// 포인터가 글자 위에 오르면 p 가 커지며 그림자 깊이가 늘고 글자가 살짝 떠오른다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.hv {
display: grid;
gap: $sp-4;
justify-items: center;
text-align: center;
}
.hv__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.hv__scene {
padding: $sp-10;
}
.hv__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: $color;
cursor: pointer;
will-change: text-shadow, transform;
}
.hv__word:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 8px;
}
@media (max-width: 340px) {
.hv__scene { padding: $sp-6; }
.hv__word { font-size: clamp(52px, 26vw, 92px); }
}
@media (prefers-reduced-motion: reduce) {
.hv__word { transition: 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);
}
.hv {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
}
.hv__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.hv__scene {
padding: 40px;
}
.hv__word {
margin: 0;
font-weight: 800;
font-size: clamp(72px, 24vw, 140px);
line-height: 1;
white-space: nowrap;
color: #fff7e6;
cursor: pointer;
will-change: text-shadow, transform;
}
.hv__word:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 8px;
}
@media (max-width: 340px) {
.hv__scene {
padding: 24px;
}
.hv__word {
font-size: clamp(52px, 26vw, 92px);
}
}
@media (prefers-reduced-motion: reduce) {
.hv__word {
transition: none;
}
}(function () {
var HOVER_LIFT_PX = 4;
var LIFT_MULTIPLIER = 5;
var LERP = 0.18;
var PERIOD_MS = 1000;
var scene = document.getElementById('hvScene');
var word = document.getElementById('hvWord');
var value = document.getElementById('hvValue');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var targetP = 0;
var curP = 0;
var hasPointer = false;
// 근접도 값(p 0~1) 하나로 그림자 오프셋·번짐 값을 함께 밀어 올려, 글자가 배경에서
// 떠오르는 것처럼 보인다. 深さ 기본 단위는 HOVER_LIFT_PX
function buildLiftShadow(p) {
var y = (p * HOVER_LIFT_PX * 1.6).toFixed(2);
var y2 = (p * HOVER_LIFT_PX * LIFT_MULTIPLIER).toFixed(2);
var blur = (4 + p * HOVER_LIFT_PX * 6.5).toFixed(1);
return [
'0px ' + y + 'px 0 rgba(10,79,232,' + (0.55 + p * 0.3).toFixed(2) + ')',
'0px ' + y2 + 'px ' + blur + 'px rgba(10,79,232,' + (0.22 + p * 0.36).toFixed(2) + ')'
].join(', ');
}
function renderLift(p) {
word.style.textShadow = buildLiftShadow(p);
word.style.transform = 'translateY(' + (-(p * HOVER_LIFT_PX * 1.2)).toFixed(2) + 'px)';
value.textContent = (p * HOVER_LIFT_PX).toFixed(1) + 'px';
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var startTime = null;
function loop(now) {
if (startTime === null) startTime = now;
if (!hasPointer && !reduce) {
var t = (now - startTime) % PERIOD_MS;
var phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
targetP = phase;
}
curP += (targetP - curP) * LERP;
renderLift(curP);
requestAnimationFrame(loop);
}
scene.addEventListener('pointerenter', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; renderLift(1); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; renderLift(0); }
});
word.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; renderLift(1); }
});
word.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; renderLift(0); }
});
if (reduce) {
renderLift(0);
} else {
requestAnimationFrame(loop);
}
})();// TextShadowHoverDepthLift — vanilla 판(run/412/demo/09-text-shadow-hover-depth-lift/index.html) 의 React 포트.
// 근접도 값(p 0~1) 하나로 그림자 오프셋·번짐 값을 함께 밀어 올린다. 포인터가 글자 위에
// 오르면 p 가 커지며 그림자 깊이가 늘고 글자가 살짝 떠오른다.
import { useEffect, useRef, useState } from "react";
import styles from "./TextShadowHoverDepthLift.module.scss";
type Box<T> = { current: T };
const HOVER_LIFT_PX = 4;
const LIFT_MULTIPLIER = 5;
const LERP = 0.18;
const PERIOD_MS = 1000;
function buildLiftShadow(p: number): string {
const y = (p * HOVER_LIFT_PX * 1.6).toFixed(2);
const y2 = (p * HOVER_LIFT_PX * LIFT_MULTIPLIER).toFixed(2);
const blur = (4 + p * HOVER_LIFT_PX * 6.5).toFixed(1);
return [
"0px " + y + "px 0 rgba(10,79,232," + (0.55 + p * 0.3).toFixed(2) + ")",
"0px " + y2 + "px " + blur + "px rgba(10,79,232," + (0.22 + p * 0.36).toFixed(2) + ")"
].join(", ");
}
export default function TextShadowHoverDepthLift() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const wordRef = useRef<HTMLHeadingElement | null>(null);
const valueRef = useRef<HTMLSpanElement | null>(null);
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const word = wordRef.current;
const value = valueRef.current;
if (!word || !value) return;
word.style.textShadow = buildLiftShadow(p);
word.style.transform = "translateY(" + (-(p * HOVER_LIFT_PX * 1.2)).toFixed(2) + "px)";
value.textContent = (p * HOVER_LIFT_PX).toFixed(1) + "px";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
if (!hasPointerRef.current) {
const t = (now - t0Ref.current) % PERIOD_MS;
const phase = (Math.sin((t / PERIOD_MS) * Math.PI * 2 - Math.PI / 2) + 1) / 2;
targetPRef.current = phase;
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
render(curPRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerEnter(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
const cls = [styles.hv, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge}><span>떠오름 값</span> <span ref={valueRef}>0.0px</span></p>
<div className={styles.scene} ref={sceneRef} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}>
<h1 className={styles.word} ref={wordRef} tabIndex={0} onFocus={onFocus} onBlur={onBlur}>부상</h1>
</div>
</div>
</main>
);
}09 · カーソルが乗ると深さの値が押し上がる文字 — 計測 累積面積 11.546% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 近接度値(p 0〜1)ひとつで影のオフセットとぼかしを一緒に押し上げ、文字が背景から
// 浮き上がって見えるようにする。深さの基準単位はHOVER_LIFT_PX
function buildLiftShadow(p) {
var y = (p * HOVER_LIFT_PX * 1.6).toFixed(2);
var y2 = (p * HOVER_LIFT_PX * LIFT_MULTIPLIER).toFixed(2);
var blur = (4 + p * HOVER_LIFT_PX * 6.5).toFixed(1);
return [
'0px ' + y + 'px 0 rgba(10,79,232,' + (0.55 + p * 0.3).toFixed(2) + ')',
'0px ' + y2 + 'px ' + blur + 'px rgba(10,79,232,' + (0.22 + p * 0.36).toFixed(2) + ')'
].join(', ');
}
測定値: 累積面積11.5%、平均強度48.3、動いたフレーム23/23。
値が影の形を変える場所 — 層の数・オフセット距離・色の分離
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 押し出し層 | 10.4% | 26.0 | 22/23 |
| 02 凹凸(明暗) | 10.9% | 129.2 | 23/23 |
| 03 八方向の輪郭 | 10.6% | 140.2 | 21/23 |
| 04 対角線のレトロな尾 | 10.1% | 140.3 | 22/23 |
| 05 RGB分離グリッチ | 10.2% | 144.0 | 23/23 |
| 06 層状の金属光沢 | 10.4% | 43.9 | 23/23 |
| 07 紙の重なり | 11.6% | 145.0 | 18/23 |
| 08 スクロール連動の深さ | 9.9% | 66.5 | 23/23 |
| 09 ホバーで持ち上がる深さ | 11.5% | 48.3 | 23/23 |
9つの中で平均強度が最も高いのは07(145.0)で、動いたフレーム数は最も少ない18/23です — PAPER_LAYER_COUNTはループの中で6段の整数状態しか取らないので、09のcurPのようになめらかに補間される値より一段あたりのピクセルの飛び幅が大きくなります。この差の大半は三つの定数が担っています — SHADOW_LAYER_COUNT(1番)はそもそも何枚のオフセットコピーが存在するかを、GLITCH_SPLIT_PX(5番)は二色がどれだけ離れるかを、HOVER_LIFT_PX(9番)はひとつのインタラクションがオフセットとぼかしをどれだけ押し上げるかを決めます — css text shadow multipleと呼べるほど何層も重ねるこれらの関数は、結局この三種類の数値のどれかを別の速さで読んでいるだけです。
どこで壊れるか — 動作を減らす設定・低スペック端末・長いテキストの折り返し
9つの.ex__word系ルールはすべてwhite-space: nowrapを指定しているので、コンテナより長い見出しは折り返さず、はみ出すか周囲に切り取られます。9つのどの関数もオフセットは行の高さや列幅ではなく一枚のピクセル数として書かれているので、二行目のためにずれを計算し直すことはありません。デモの一文字か二文字の単語では問題になりませんが、狭いスマホ画面で実際の見出しが三語・四語と長くなった瞬間、clamp()がフォントサイズを縮めてもレイアウトの規則自体は変わらないので効かなくなります。
層の数はただの見た目ではなく、フレームごとのコストでもあります — 1番のバリエーションはSHADOW_LAYER_COUNTを14まで押し上げられ、3番は常に八層、7番は紙の層ひとつにつき影を二枚描きます。clamp()が現在描いている大きさのグリフ輪郭を、ループが動いている間ずっと毎フレーム塗り直すということです。低スペックの端末では、8番の二層構成(実際のscrollイベントでしか計算し直さない)より、連続したrequestAnimationFrameループで十数層以上を140pxのtext-shadowに描くほうが、フレームあたりの塗り作業ははっきり重くなります。
prefers-reduced-motion: reduceはすべてのrequestAnimationFrameループを止め、各パターンをひとつの固定状態に留めます — 多くは最大値(1・3・4・6・7番は最大値へ直行)ですが、8番はもともとタイマーで動いていなかったので例外、9番は実際のホバーやフォーカスには応答し続けるもののLERPの補間なしで満杯まで一気に跳びます。正直に言っておくべき例外が5番です — モーション低減設定の下でもクリックすれば基準値の14倍まで220msの単発バーストが起きます。これは連続したアニメーションではなく、クリックという操作に対する一回きりの状態変化なので、無効化せずそのまま残しています。9つすべてのindex.html・style.scssを開けるkdbb8nchはこの段落にわざと置いています — ここまで読んだ人は、九つの定数のうちどれを最初に変えたいかもう分かっているはずです。
prefers-reduced-motionのアクセシビリティが3つのバリエーションでも変わらない理由
| バリエーション | 値の変化 | 見え方の変化 |
|---|---|---|
| 立体の層数値を調整する | SHADOW_LAYER_COUNT 6 → 14 |
値を減らすと文字は薄く平たい立体に見え、増やすと層が幾重にも重なりずっと厚い立体に見える |
| グリッチの色分離距離値を調整する | GLITCH_SPLIT_PX 2 → 6 |
値を減らすと色はわずかにずれてほのかに揺らぎ、増やすと色が大きく離れてより荒く壊れたように見える |
| ホバー時の深さ上昇値を調整する | HOVER_LIFT_PX 4 → 12 |
値を減らすとホバーしても影はわずかしか伸びず変化が分かりにくく、増やすと影が大きく伸び文字が画面から浮いたように見える |
3つのバリエーションのどれも、上で触れたモーション低減設定の挙動そのものは変えません — SHADOW_LAYER_COUNTが6でも14でもprefers-reduced-motion: reduceの下では最大値へ直行しますし、HOVER_LIFT_PXが4でも12でもpointerenterとfocusにはなめらかな補間なしで反応します。どの定数を動かしても9つのアクセシビリティ面で変わらないことがひとつあります — 9つの文字はどれもaria-hiddenを持たないので、スクリーンリーダーは文字そのものを読み上げ、背後に積まれた影の層には一切触れません。影は読み飛ばしてかまわない装飾のままです。
css text-shadow ジェネレーターや既定のtext-shadowと、この9パターンは何が違うか?
手で書くtext-shadowは数枚のコンマ区切りレイヤーを一度決めて終わりです — デザイナーがオフセットを一度選べば、CSSはそれ以降変わりません。上の九つはどれも同じプロパティを毎フレームか毎イベントごとにひとつの数値から書き直しているだけで、実際の違いはCSSプロパティ自体(9つとも結局.style.textShadowを書いています)ではなく、その数値を人が選ぶかループが選ぶかです。値を一度だけ決めて出力する生成ツールは前者の代わりにしかならず、ループが毎フレーム読み替える9つの関数そのものの代わりにはなりません。MDNのfilterリファレンスがdrop-shadow()という最も近いCSS単体の代替を扱っています — グリフの実際のアルファ形状に沿う点はtext-shadowと違いますが、重ねがけはできないので、上の9つのどれも使っていません。
css text-shadowを触る前によくある質問
css text-shadowを何層も重ねると文字の描画は重くなりますか?
層の数とグリフの大きさに比例して重くなります — 各層は同じ文字の背後にある別々の塗りパスなので、3番の固定八層は2番の二層より毎フレームのコストが高く、1番のバリエーションでSHADOW_LAYER_COUNTを14まで上げると既定の6のおよそ倍近いコストになります。9つのどれもレイアウトは変えません(text-shadowは要素の箱を変更しません)から、コストはリフローではなくペイントにとどまります — 遅い端末では確かに重くなりますが、上限のある重さです。
5番のグリッチはなぜクリックするたびに崩れ方が変わるのですか?
seedRand()が固定シードの生成器で、毎回同じ11というシードから始まる決定的な数列を返すからです。クリックするとseedの式自体が新しい数へ書き換わり、そこからseedRand()を作り直すので、次に返ってくる数列がそのつど変わって見えます — 本物の乱数ではなく、毎回違うスタート地点から同じ計算をやり直しているだけです。
css text shadow syntaxの基本だけ知りたい場合、この9パターンは複雑に見えませんか?
text-shadowの構文自体はoffset-x offset-y blur-radius colorという4つの値だけで、9つのどのパターンも結局この4つに数値を書き込んでいるにすぎません。複雑に見えるのはループやseedRand()のような周辺のJavaScriptのほうで、まず1番か2番のbuildExtrudeShadow()・buildEmbossShadow()だけを読めば、残り7つも同じ4値構文の上に別の計算を足しているだけだと分かります。
同じく「ひとつの値が場面全体を動かす」という考え方でまとめた編としては、【コピペOK】CSS ネオン・グロー効果9選が色とぼかしを深さの代わりに動かしていて近く、CSS 下線 アニメーション9選 — 一行でコピペOKは2番の明暗が逆になる凹凸と見比べる価値があります — どちらも文字そのものには触れずに、背景に対する読み方だけを変えるからです。そして【コピペOK】css テキスト アニメーション 9選はもっとも近い親戚です — あちらは文字そのものを動かして際立たせ、ここでは影だけを動かして際立たせています。









