08스크롤 값만큼 그림자가 자라나는 글자
페이지를 내리는 동안 스크롤 진행 값을 그림자 거리 값에 그대로 이어, 내려갈수록 그림자가 점점 자라난다.
쓰임긴 소개 페이지 섹션 제목
SCROLL_MAX_PXbuildScrollShadowrenderScroll

<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 원작 · zip 에 SCSS·React 포함 · css 텍스트 그림자 효과 9가지 — 층 값이 입체를 만든다
이 데모가 속한 글
css 텍스트 그림자 효과 9가지 — 층 값이 입체를 만든다css 텍스트 그림자 효과는 text-shadow 값 하나를 층 문자열로 계산해 조립하는 아홉 가지 패턴이다. 그림자는 빛이 사라진 자리인데, 이 아홉 글자는 그림자만으로 빛을 대신한다 — 01번은 겹친 층 값으로 이 글의 데모 전부 보기


