GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css 그림자 효과 9가지 — 광원 위치에서 그림자를 계산하는 장면

css 그림자 효과를 box-shadow 한 줄로 끝내면 광원이 돌아도 그림자는 제자리고, 벽 뒤까지 빛이 새고, 두 빛이 만나도 그림자는 하나다 — 01번·05번·08번이 계산으로 푼다.

그림자 아홉 장면을 화면에서 바로 본다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

css 그림자 효과 9가지 — 광원에서 계산해야 그림자가 제자리에 있지 않는다

아홉 장면은 모두 480×300(W·H) 캔버스 한 장에 그리고, 입력은 광원 목표점 하나다. 장면은 목표점을 LIGHT_LERP 비율로 따라가며 매 프레임 그림자를 처음부터 다시 계산하고, 시간은 16밀리초(FIXED_DT) 단위로 끊어 쓴다. 캔버스를 누르기 전에는 가상 경로가 광원을 몰고, 누른 뒤에는 포인터와 방향키가 광원을 움직인다.

css 박스 그림자 한 줄은 광원이 어디 있는지 모르는 값이다. box-shadow: 8px 12px 20px 의 오프셋은 이미 정해진 숫자라서 광원이 돌아도 따라 돌지 않는다. 아홉 장면은 이 숫자를 손으로 쓰지 않고 광원 위치와 높이에서 꺼내게 바꿨다.

계산의 뼈대는 한 줄이다. 광원 (lx, ly) 와 높이 lh, 물체의 점 (x, y) 와 높이 h 가 있으면 바닥 그림자는 광원 + (점 − 광원) × lh ÷ (lh − h) 에 떨어진다. 점이 높을수록 분모가 줄어 광원 반대쪽으로 더 멀리 밀리고, 분모는 최소 8 로 묶어 0 으로 나누는 일을 막는다. 01·02·08·09 는 이 식을 상자 윗면 네 점에 적용해 볼록 껍질로 묶고, 03·04·06·07 은 같은 생각을 높이에서 번짐·길이·농도를 뽑는 식으로 바꿨으며, 05 는 투영 대신 광선으로 빛을 가린다.

글자 위에 값을 겹쳐 입체를 만드는 쪽은 css 텍스트 그림자 효과 9가지 — 층 값이 입체를 만든다가 다루고, 이 편은 그림자 모양 자체를 광원 위치에서 계산하는 데 집중한다. 같은 입력이면 같은 그림이 나오고, Date·fetch·저장소·WebGL·라이브러리는 아홉 파일 어디에도 없다.

기법별 코드로 보는 그림자

아래 조각은 각 장면 index.html 의 이어진 줄을 잘라 붙인 것이고, 처음부터 끝까지 이어진 파일은 압축 파일 쪽에서 연다. 장면마다 makeScene 이 다르고 입력·시간·버튼을 맡는 main 은 아홉 파일이 같은 뼈대를 쓴다.

01광원이 돌 때 바닥에 투영되는 그림자

castPoint 가 광원 높이 220 과 상자 높이 70 에서 배율 220÷(220−70) = 1.47 을 구해 윗면 네 꼭짓점을 광원 반대편 바닥으로 밀어 내고, boxShadow 가 바닥 네 점과 밀려난 네 점을 볼록 껍질로 묶어 한 덩어리로 칠한다. 광원이 타원 궤도(ORBIT_RADIUS 110)를 한 바퀴 돌면 그림자는 늘 반대쪽에 서서 같이 돈다. 측정은 누적면적 63.4%·평균 강도 40.4·23/23 프레임이다.

광원이 돌 때 바닥에 투영되는 그림자
새 창에서 열기

01 · 광원이 돌 때 바닥에 투영되는 그림자 — 측정 누적면적 63.387% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIGHT_HEIGHTORBIT_RADIUSLIGHT_LERPBOX_HEIGHT
    function castPoint(lx, ly, lh, x, y, h) {
        const k = lh / Math.max(lh - h, 8);
        return [lx + (x - lx) * k, ly + (y - ly) * k];
    }
    function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
        const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
        const pts = base.slice();
        for (let i = 0; i < base.length; i += 1) {
            pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
        }
        return hull(pts);
    }
            path: function (t) {
                const a = PI2 * t / PERIOD_MS;
                return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
            },
            pos: function () { return [lx, ly]; },
            update: function (tx, ty) {
                lx = lerp(lx, tx, LIGHT_LERP);
                ly = lerp(ly, ty, LIGHT_LERP);
            },

02포인터를 광원으로 삼는 기둥 줄

광원 하나가 LIGHT_LERP 0.12 비율로 목표점을 따라가고, 기둥 여섯 개는 시드 444 로 정한 높이 67·101·88·59·64·74 에 맞춰 그림자를 각자 다시 투영한다. 동작 줄임이 꺼져 있으면 시연 중에는 가상 경로로 움직이고 캔버스를 누르면 그때부터 포인터를 따라간다(동작 줄임이 켜져 있으면 누르지 않아도 포인터 이동만으로 광원이 따라온다). 측정은 누적면적 35.0%·평균 강도 44.0·23/23 프레임이다.

포인터를 광원으로 삼는 기둥 줄
새 창에서 열기

02 · 포인터를 광원으로 삼는 기둥 줄 — 측정 누적면적 34.99% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIGHT_LERPPILLAR_COUNTPILLAR_WIDTHLIGHT_HEIGHT
    function makeScene(ctx) {
        let lx = 240;
        let ly = 200;
        const rnd = mulberry32(SEED);
        const heights = [];
        for (let i = 0; i < PILLAR_COUNT; i += 1) {
            heights.push(58 + Math.floor(rnd() * 44));
        }
        function pillarX(i) { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
                for (let i = 0; i < PILLAR_COUNT; i += 1) {
                    const x = pillarX(i);
                    const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
                    fillShadow(ctx, poly, SHADOW_RGB, 0.44);
                }

03높이에 따라 흐려지는 그림자

판을 64개 띠로 쪼개 띠마다 바닥에서 뜬 높이를 구하고, 번짐은 2 + 높이×BLUR_PER_HEIGHT, 농도는 CONTACT_ALPHA÷(1 + 높이×0.03) 으로 계산해 띠마다 세로 그라데이션을 칠한다. 닿은 끝 띠(높이 0.86)는 번짐 2.17px·농도 0.604 이고 뜬 끝 띠(높이 109.14)는 번짐 23.83px·농도 0.145 라서 한 판 안에서 선명함이 갈린다. 측정은 누적면적 32.8%·평균 강도 44.9·23/23 프레임이다.

높이에 따라 흐려지는 그림자
새 창에서 열기

03 · 높이에 따라 흐려지는 그림자 — 측정 누적면적 32.814% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLUR_PER_HEIGHTCONTACT_ALPHALIFT_MAX
                for (let i = 0; i < STRIPS; i += 1) {
                    const h0 = heightAt(i / STRIPS, total);
                    const h1 = heightAt((i + 1) / STRIPS, total);
                    const hm = (h0 + h1) / 2;
                    const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
                    const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
                    const blur = 2 + hm * BLUR_PER_HEIGHT;
                    const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
                    const yTop = BASE_Y - 2;
                    const yBot = BASE_Y + 16 + hm * 0.5;
                    const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
                    const span = yBot - yTop + 2 * blur;
                    g.addColorStop(0, rgba(SHADOW_RGB, 0));
                    g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
                    g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
                    g.addColorStop(1, rgba(SHADOW_RGB, 0));
                    ctx.fillStyle = g;
                    ctx.fillRect(xa, yTop - blur, xb - xa, span);
                }

04가상 해가 호를 그리는 해시계

해의 진행 하나에서 고도를 14° + 56°×sin(π·진행) 으로 정하고, 그림자 길이는 GNOMON_HEIGHT÷tan(고도) 를 SHADOW_LEN_MAX 140 으로 잘라 구한다. 방향은 해 각도의 반대쪽이라, 해가 160° 호를 지나는 동안 그림자가 길었다 짧아지며 반대로 돈다. 측정은 누적면적 28.5%·평균 강도 37.1·23/23 프레임이다.

가상 해가 호를 그리는 해시계
새 창에서 열기

04 · 가상 해가 호를 그리는 해시계 — 측정 누적면적 28.476% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SUN_ARC_DEGSHADOW_LEN_MAXGNOMON_HEIGHT
        // 해의 진행 u (0 은 해 뜬 직후, 1 은 지기 직전) 를 화면 각도로 바꾼다
        function sunAngle(u) {
            const mid = 270;
            return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
        }
        function elevation(u) { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
                const phi = sunAngle(ux);
                const e = elevation(ux);
                const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));

05벽에 가려지는 빛의 영역

visibilityPolygon 이 균등 광선 RAY_COUNT 180 개와 벽 선분마다 한 끝점을 향해 −ANGLE_EPS·0·+ANGLE_EPS 로 어긋나게 쏜 광선 세 개씩을 모아 각도 순으로 세우고, castRay 가 가장 가까운 교차점을 찾아 그 점들을 이어 빛 다각형을 만든다. 이 다각형 안쪽만 clip 해 밝게 칠하니 벽 뒤는 어두운 채 남는다. 측정은 누적면적 65.6%·평균 강도 74.3·23/23 프레임이다.

벽에 가려지는 빛의 영역
새 창에서 열기

05 · 벽에 가려지는 빛의 영역 — 측정 누적면적 65.603% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RAY_COUNTWALL_SEGMENTSANGLE_EPSLIGHT_RADIUS
    function castRay(ox, oy, dx, dy) {
        let best = 4000;
        for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
            const s = WALL_SEGMENTS[i];
            const ex = s[2] - s[0];
            const ey = s[3] - s[1];
            const den = dx * ey - dy * ex;
            if (Math.abs(den) < 1e-9) {
                continue;
            }
            const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
            const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
            if (t > 0 && u >= 0 && u <= 1 && t < best) {
                best = t;
            }
        }
        return best;
    }
    function visibilityPolygon(ox, oy) {
        const angles = [];
        for (let i = 0; i < RAY_COUNT; i += 1) {
            angles.push(PI2 * i / RAY_COUNT);
        }
        for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
            const s = WALL_SEGMENTS[i];
            const a = Math.atan2(s[1] - oy, s[0] - ox);
            angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
        }
        angles.sort(function (p, q) { return p - q; });
        const out = [];
        for (let i = 0; i < angles.length; i += 1) {
            const dx = Math.cos(angles[i]);
            const dy = Math.sin(angles[i]);
            const d = castRay(ox, oy, dx, dy);
            out.push([ox + dx * d, oy + dy * d]);
        }
        return out;
    }

06층 높이에서 계산되는 카드 그림자 값

shadowOf 가 층 번호 하나에서 오프셋 level × ELEV_STEP × (LIGHT_HEIGHT÷180), 번짐 2 + level × ELEV_STEP × 0.9, 농도 min(0.12 + 0.045·level, 0.6) 을 뽑아 캔버스 shadowBlur 로 두 겹을 칠한다. 층 1 은 오프셋 3.33·번짐 5.6·농도 0.165, 층 5 는 16.67·20.0·0.345 이고 이 세 값은 그대로 box-shadow 의 오프셋·번짐·알파로 옮길 수 있다. 측정은 누적면적 41.6%·평균 강도 28.5·23/23 프레임이다.

층 높이에서 계산되는 카드 그림자 값
새 창에서 열기

06 · 층 높이에서 계산되는 카드 그림자 값 — 측정 누적면적 41.603% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ELEV_STEPSHADOW_ALPHA_BASEHOVER_BONUS
    function shadowOf(level, dx, dy) {
        const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
        const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
        return {
            ox: (dx / dist) * reach,
            oy: (dy / dist) * reach + level * 0.5,
            blur: 2 + level * ELEV_STEP * 0.9,
            alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
        };
    }
                    const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
                    // 큰 번짐의 주변광 층과 짧은 주 그림자 층을 겹친다
                    ctx.shadowColor = rgba(INK_RGB, s.alpha);
                    ctx.shadowBlur = s.blur;
                    ctx.shadowOffsetX = s.ox;
                    ctx.shadowOffsetY = s.oy;
                    ctx.fillStyle = rgba(CARD_RGBS[i], 1);
                    ctx.fillRect(x, y, CARD_W, CARD_H);

07물체가 내려앉을수록 짙어지는 접촉 그림자

contactAlpha 는 높이 h 를 HEIGHT_MAX 96 으로 나눈 값에서 AO_STRENGTH×(1 − h÷96)² + 0.05 로 짙기를 정하고, contactRadius 는 반지름을 (AO_RADIUS + 반폭×0.4)×(1 + 1.2·h÷96) 으로 넓힌다. 반폭 26 인 구는 바닥에 닿으면 농도 0.71·반지름 36.4 이고 가장 높은 96 에서는 농도 0.05·반지름 80.1 로 풀어지며, 이 그림자는 DOM 의 box-shadow 가 아니라 캔버스에 칠한 타원이다. 측정은 누적면적 16.8%·평균 강도 41.9·23/23 프레임이다.

물체가 내려앉을수록 짙어지는 접촉 그림자
새 창에서 열기

07 · 물체가 내려앉을수록 짙어지는 접촉 그림자 — 측정 누적면적 16.829% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

AO_RADIUSAO_STRENGTHHEIGHT_MAX
    function contactAlpha(h) {
        const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
        return AO_STRENGTH * near * near + 0.05;
    }
    function contactRadius(h, half) {
        return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
    }

08두 색 광원이 겹쳐 만드는 이중 그림자

광원마다 오프스크린 층에 색 웅덩이를 그리고 destination-out 으로 자기 그림자 다각형만 지운 뒤 lighter 로 본 캔버스에 더한다. 그래서 한 빛만 막힌 곳은 다른 빛의 색이 남고 두 빛이 모두 막힌 곳만 바닥색 그대로 가장 어둡다. 측정은 누적면적 66.3%·평균 강도 34.1·23/23 프레임이다.

두 색 광원이 겹쳐 만드는 이중 그림자
새 창에서 열기

08 · 두 색 광원이 겹쳐 만드는 이중 그림자 — 측정 누적면적 66.294% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIGHT_A_HUELIGHT_B_HUELIGHT_GAPLIGHT_HEIGHT
        function lights(t) {
            const a = t * AXIS_SPEED;
            const dx = Math.cos(a) * LIGHT_GAP / 2;
            const dy = Math.sin(a) * LIGHT_GAP / 2;
            return [
                [clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
                [clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
            ];
        }
                    lctx.globalCompositeOperation = 'source-over';
                    lctx.clearRect(0, 0, W, H);
                    const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
                    g.addColorStop(0, lightColor(hues[i], 0.62));
                    g.addColorStop(1, lightColor(hues[i], 0.05));
                    lctx.fillStyle = g;
                    lctx.fillRect(0, 0, W, H);
                    lctx.globalCompositeOperation = 'destination-out';
                    lctx.fillStyle = 'rgba(0,0,0,1)';
                    for (let b = 0; b < BLOCKS.length; b += 1) {
                        const r = BLOCKS[b];
                        const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
                        polyPath(lctx, poly);
                        lctx.fill();
                    }
                    ctx.globalCompositeOperation = 'lighter';
                    ctx.drawImage(layer, 0, 0);
                }

09해가 지고 램프가 켜지는 책상 히어로

진행 하나에서 해 높이를 420 에서 70 으로 lerp 하고 램프 켜짐은 smooth((p−0.35)÷0.4), 해 농도는 1 − 0.9·smooth((p−0.55)÷0.45) 로 정해 같은 상자 세 개에 해 그림자와 램프 그림자를 따로 겹쳐 칠한다. 진행 0.75 에서 해 높이는 124.7·램프 켜짐 1·해 농도 0.625 라서 두 그림자가 함께 보이고, 진행 1 에서는 해 농도 0.1 만 남는다. 측정은 누적면적 70.1%·평균 강도 21.4·18/23 프레임이다.

해가 지고 램프가 켜지는 책상 히어로
새 창에서 열기

09 · 해가 지고 램프가 켜지는 책상 히어로 — 측정 누적면적 70.11% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DUSK_FRAMESLAMP_HEIGHTSUN_HEIGHT_DUSK
                const night = smooth(p);
                const lampOn = smooth((p - 0.35) / 0.4);
                const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
                const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
                ctx.fillStyle = rgba(desk, 1);
                ctx.fillRect(0, 0, W, H);
                glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
                glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
                const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
                for (let i = 0; i < ITEMS.length; i += 1) {
                    const r = ITEMS[i];
                    fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
                    fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
                }

값이 장면을 바꾸는 곳 — 광원 높이·광선 수·두 광원 간격

손대면 장면이 달라지는 값은 셋이다. 01 의 광원 높이 LIGHT_HEIGHT, 05 의 광선 수 RAY_COUNT, 08 의 두 광원 간격 LIGHT_GAP 이다. 흐릿함은 03 의 BLUR_PER_HEIGHT 가 정하고, css 그림자 투명도에 해당하는 농도는 06 의 SHADOW_ALPHA_BASE 와 ELEV_STEP 이 정한다.

광원 높이를 낮추면 그림자가 길게 늘어난다

LIGHT_HEIGHT 220 에서 상자 윗면(높이 70)의 배율은 220÷150 = 1.47 이다. 120 으로 낮추면 120÷50 = 2.4 가 되어 그림자가 훨씬 길게 뻗고, 400 으로 올리면 400÷330 = 1.21 로 상자 바로 옆에 붙는다.

광선을 두 배로 늘려도 빛의 모양은 같다

05 의 RAY_COUNT 를 180 에서 360 으로 늘리면 다각형 꼭짓점이 264개(180 + 3×28)에서 444개로 늘지만 빛이 닿는 모양은 그대로다. 모양을 정하는 것은 벽 선분 방향으로 쏜 광선이고 균등 광선은 곧은 변을 쪼갤 뿐이라, 늘어나는 것은 광선×선분 교차 계산 비용뿐이다.

두 광원 간격을 벌리면 겹침이 줄어든다

LIGHT_GAP 160 을 260 으로 벌리면 상자마다 색상 330 과 170 의 두 빛이 만드는 그림자가 갈라지는 폭이 커지고, 두 빛이 모두 막혀 가장 어두운 겹침 영역은 줄어든다. 두 광원은 중점을 기준으로 AXIS_SPEED 0.0008 라디안/ms 로 도는 축 위에 놓여, 간격은 그 축을 따라 벌어진다.

아홉 장면을 렌더해서 잰 값은 아래 표에 모았다. 04 와 07 을 뺀 일곱 장면이 누적면적 30% 를 넘고 01·05·08·09 는 60% 를 넘으며, 접촉 그림자 하나뿐인 07 이 16.8% 로 가장 작아도 10% 아래로 내려간 장면은 없다. 움직인 프레임은 09 만 18/23 이고 나머지는 23/23 이라, 시연 동안 화면이 거의 쉬지 않고 바뀐다.

항목 누적면적 평균 강도 움직인 프레임
01 광원 궤도 투영 63.4% 40.4 23/23
02 포인터 기둥 줄 35.0% 44.0 23/23
03 높이별 번짐 32.8% 44.9 23/23
04 해시계 28.5% 37.1 23/23
05 벽 가림 다각형 65.6% 74.3 23/23
06 층 높이 카드 41.6% 28.5 23/23
07 접촉 그림자 16.8% 41.9 23/23
08 두 색 광원 66.3% 34.1 23/23
09 램프 히어로 70.1% 21.4 18/23

어디서 깨지는가 — 좁은 화면·광원이 물체 안으로 들어올 때·읽히지 않는 대비

광원이 물체 높이 아래로 내려오면 배율이 튄다

01 에서 광원 높이가 상자 높이 70 이하가 되면 분모 lh − h 가 8 로 묶이면서 배율이 70÷8 = 8.75 로 튄다. 광원이 물체보다 낮으면 식 자체가 성립하지 않아 이 묶음은 안전장치일 뿐이니, 높이는 물체 위에 둔다. 02 도 가장 높은 기둥(101) 근처까지 LIGHT_HEIGHT 를 내리면 같은 묶음이 먼저 걸린다.

해가 지평선 가까우면 그림자가 잘린다

04 에서 해 진행이 0.04 이면 고도 21.0° 라서 원래 길이는 156.2 인데 SHADOW_LEN_MAX 140 에서 잘린다. 같은 장면에서 진행 0.25 는 고도 53.6°·길이 44.2, 진행 0.5 는 고도 70°·길이 21.8 이라 낮 한가운데는 그림자가 가장 짧다.

벽 사이 틈이 좁으면 광원이 못 들어간다

05 는 벽 사이 틈이 WALL_MARGIN 4 의 두 배(8)보다 좁으면 광원이 틈에 들어가지 못하고 pushOut 이 가장 가까운 바깥 변으로 밀어 낸다. 광원을 벽 안쪽에 놓으면 광선이 곧바로 벽에 막혀 다각형이 무너지기 때문이다.

가장자리에서는 두 광원이 가까워진다

08 의 두 광원은 각각 캔버스 가장자리에서 10px 안쪽으로 묶인다. 중점이 가장자리 근처에 가면 실제 간격이 LIGHT_GAP 보다 줄어들어, 같은 값인데도 겹침 영역이 더 커진다.

어두운 바닥에서는 그림자 색을 그대로 못 쓴다

01 은 밝은 바닥 [243, 238, 250] 에 그림자 색 [74, 56, 120] 을 농도 0.46 으로 얹는다. 같은 값을 어두운 보라 바닥에 옮기면 그림자가 바닥에 묻혀 읽히지 않는다. 08 의 바닥이 어두운 보라 [58, 50, 86] 인 것은 색광이 겹치는 모습을 보이려는 장면상 예외이고, 08 은 그림자를 칠하는 대신 빛을 더하는 방식이라 어두운 바닥에서도 겹침이 읽힌다.

좁은 화면과 움직임 판정

캔버스는 aspect-ratio 480 / 300 으로 폭만 줄고, 아홉 장면의 넘침·폰 폭 넘침 판정은 모두 아니오다. 09 의 움직인 프레임이 18/23 인 것은 시연 경로가 좌우로 왕복하다 양 끝에서 감속해 프레임 사이 변화가 없는 구간이 생기기 때문이다.

직접 고쳐 보며 경계를 확인하는 법

02 의 광원 높이와 01 의 상자 높이, 04 의 상한과 05 의 마진을 한 번씩 바꿔 가며 위 경계가 실제로 나타나는 값을 찾을 수 있다. 어느 값에서 어떻게 틀어지는지 손으로 확인하고 싶다면 아래 파일을 비밀번호 6bvjpvkj 으로 연 뒤 이 상수들을 하나씩 바꿔 본다.

변형 세 가지와 접근성

이름 값 느낌
광원 높이 바꾸기 01 LIGHT_HEIGHT 220 → 120 상자 윗면이 바닥에 그리는 거리의 배율이 1.47배(220÷150)에서 2.4배(120÷50)로 늘어 그림자가 훨씬 길게 늘어나고, 400 으로 올리면 1.21배로 줄어 짧아진다.
광선 수 늘리기 05 RAY_COUNT 180 → 360 다각형 꼭짓점이 264개에서 444개로 늘지만 빛이 닿는 모양은 같다. 늘어나는 것은 광선×선분 교차 계산 비용이다.
두 광원 간격 바꾸기 08 LIGHT_GAP 160 → 260 상자마다 색상 330 과 170 의 두 빛이 만드는 그림자가 갈라지는 폭이 커지고, 두 빛이 모두 막혀 어두운 겹침 영역은 줄어든다.

prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고 1000밀리초 지점의 장면 한 장을 그린다. 이 설정에서 버튼 글자는 「다음 장면」 으로 바뀌고, 한 번 누르면 시연 경로가 900밀리초 앞으로 가서 보간 없이 바로 그려지며 포인터와 방향키 입력도 같은 식으로 즉시 반영된다. 캔버스는 tabindex 0 이라 포커스를 받고, 방향키는 광원을 12px 씩 옮기고 Enter·Space 는 버튼과 같은 동작을 한다. 상태 글자는 aria-live="polite" 한 줄이라 시연 중·일시정지됨·직접 조작 중이 읽히고, 버튼은 <button> 하나로 멈춤과 재생을 오간다.

css 그림자 넣기는 box-shadow 한 줄이면 충분한가?

광원이 고정이고 모양이 사각형인 카드라면 충분하다. box-shadow 는 오프셋·번짐·색 세 값으로 끝나고, 정적인 목록 카드에 광원 계산을 얹는 것은 비용만 늘린다. 값이 광원에 묶여야 할 때부터 계산이 필요하다.

각 장면이 붙는 자리는 다르다. 01 은 상품 상자를 소개하는 장면, 02 와 09 는 랜딩 히어로 배경, 03 은 떠 있는 카드와 플로팅 버튼, 04 는 시간대를 소개하는 섹션, 05 는 던전 지도와 방 구조 소개, 06 은 대시보드 패널과 카드 겹침, 07 은 책상 위 소품과 상품 사진 합성, 08 은 무대 조명 같은 이벤트 배너에 맞다. 06 의 세 값은 CSS 로 그대로 옮길 수 있고, 07 은 타원 그라데이션이라 box-shadow 값이 아니라 농도·반지름 식을 가져다 쓴다.

물체를 회전 계산으로 그리는 장면은 주사위 굴리기 9가지 — 회전 행렬로 윗면을 읽어 내는 3D 장면과 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면에서 이어지고, 그림자를 계산하는 쪽은 여기서 광원 한 개와 두 개까지 다룬다.

css 그림자 효과 전에 자주 묻는 질문

css 그림자 그라데이션처럼 가장자리가 서서히 옅어지는 그림자는 어떻게 만드나?

03 이 그 계산이다. 띠마다 높이에 비례해 번짐을 키우고 농도를 줄여 세로 그라데이션으로 칠하면 닿은 끝은 짙고 뜬 끝은 넓게 옅어진다. 값을 옮길 때는 BLUR_PER_HEIGHT 와 CONTACT_ALPHA 두 개부터 바꾼다.

내 페이지에 붙이려면 무엇부터 바꾸나?

압축 파일에서 장면 하나의 index.html 을 열고 위쪽 상수 LIGHT_HEIGHT 와 색 배열을 먼저 바꾼다. 캔버스는 480×300 비율이라 자리에 맞게 CSS 폭만 조절하면 된다.

라이브러리나 이미지가 필요한가?

필요 없다. 장면의 박스·글자·그림자를 전부 캔버스 2d 로 그리고, 라이브러리·이미지·외부 주소 호출이 없어서 파일 하나만 옮겨도 돈다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.