08Scattered light rays
The silhouette layer is stacked twelve times, each copy scaled up a little around the light source. Because the lighter composite brightens where copies overlap, the light leaking through the gaps grows into a fan of rays.
Best forLight shafts laid over a forest or window photo
globalCompositeOperationlightercreateLinearGradient

<main class="stage">
<div class="ls" id="ls">
<p class="ls__hint">
<svg class="ls__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M184 128a56 56 0 1 1-56-56a56 56 0 0 1 56 56" opacity=".2"/><path d="M120 40V16a8 8 0 0 1 16 0v24a8 8 0 0 1-16 0m72 88a64 64 0 1 1-64-64a64.07 64.07 0 0 1 64 64m-16 0a48 48 0 1 0-48 48a48.05 48.05 0 0 0 48-48M58.34 69.66a8 8 0 0 0 11.32-11.32l-16-16a8 8 0 0 0-11.32 11.32Zm0 116.68l-16 16a8 8 0 0 0 11.32 11.32l16-16a8 8 0 0 0-11.32-11.32M192 72a8 8 0 0 0 5.66-2.34l16-16a8 8 0 0 0-11.32-11.32l-16 16A8 8 0 0 0 192 72m5.66 114.34a8 8 0 0 0-11.32 11.32l16 16a8 8 0 0 0 11.32-11.32ZM48 128a8 8 0 0 0-8-8H16a8 8 0 0 0 0 16h24a8 8 0 0 0 8-8m80 80a8 8 0 0 0-8 8v24a8 8 0 0 0 16 0v-24a8 8 0 0 0-8-8m112-88h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16"/></g></svg>
<span>Move the cursor and the light source follows slowly, fanning light into rays</span>
</p>
<div class="ls__field" id="field" role="application" aria-label="Light scatter test board" tabindex="0">
<canvas class="ls__cv" id="cv"></canvas>
<b class="ls__badge" aria-hidden="true"><span class="ls__badgeNum">12</span><span class="ls__badgeUnit">layers stacked</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 빛 산란 광선 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다(01·05 항목과 같은 방식).
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.ls {
// 노을 하늘 — 위는 태운 잉크빛, 아래는 호박색. 밑그림의 밝기가 곧 광선의 양식이다
--sky-0: #{mix($stage-ink, $stage-orange, 68%)};
--sky-1: #{mix($stage-orange, $stage-ink, 22%)};
--glow-0: #{rgba($subject-cream, .98)};
--glow-1: #{rgba($subject-cream, 0)};
// 실루엣 — 먼 능선은 공기색이 묻어 옅고, 앞 능선과 나무는 거의 잉크다(어두울수록 덜 더해진다)
--ridge-far: #{mix($stage-ink, $stage-orange, 72%)};
--ridge-near: #{mix($stage-ink, $stage-orange, 93%)};
--tree: #{mix($stage-ink, $stage-orange, 97%)};
// 가늘고 긴 구름 — 하늘의 가림막. 구름 사이 틈으로 새는 빛이 하늘에도 광선을 만든다
--cloud: #{mix($stage-ink, $stage-orange, 66%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.ls__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-yellow; }
.ls__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-orange;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .18);
cursor: crosshair; touch-action: none;
}
.ls__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ls__cv { display: block; width: 100%; height: 100%; }
// 겹 수를 화면에도 적어 둔다 — JS 의 STACK 과 같은 값이다
.ls__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ls__badgeNum { font-size: 13px; color: $stage-yellow; }
.ls__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ls { gap: $sp-1; }
.ls__field { height: 124px; }
.ls__badge { font-size: 10px; padding: 0 $sp-1; }
.ls__badgeNum { font-size: 11px; }
}
// reduce — 루프가 아예 안 돈다. 광선이 다 자란 정적 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field { cursor: default !important; }
.ls.is-still .ls__badge { opacity: .92 !important; }
}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);
}
.ls {
--sky-0: rgb(38.1333333333%, 14.9960784314%, 10.8235294118%);
--sky-1: rgb(29.0352941176%, 12.7607843137%, 10.6274509804%);
--glow-0: rgba(255, 247, 230, 0.98);
--glow-1: rgba(255, 247, 230, 0);
--ridge-far: rgb(34.4941176471%, 14.1019607843%, 10.7450980392%);
--ridge-near: rgb(15.3882352941%, 9.4078431373%, 10.3333333333%);
--tree: rgb(11.7490196078%, 8.5137254902%, 10.2549019608%);
--cloud: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.ls__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.ls__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ff4d1f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.18);
cursor: crosshair;
touch-action: none;
}
.ls__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ls__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ls__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ls {
gap: 4px;
}
.ls__field {
height: 124px;
}
.ls__badge {
font-size: 10px;
padding: 0 4px;
}
.ls__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__field {
cursor: default !important;
}
.ls.is-still .ls__badge {
opacity: 0.92 !important;
}
}// 빛 산란 광선 — 밑그림(그라디언트 하늘 + 실루엣 능선·나무)을 광원 기준으로 매번 1.03배씩
// 키우며 lighter 합성으로 열두 번 겹쳐 더한다. 겹칠수록 밝아지는 성질 때문에 나무 사이 틈으로
// 새어 나온 밝은 하늘이 부챗살 광선으로 자란다 — 광선을 그린 게 아니라 쌓아 자라게 한 것이다.
// 겹마다 알파를 광원에서 멀어지는 선형 그라디언트로 깎아, 광원 가까운 밝기만 누적에 기여하게 한다.
var root = document.getElementById('ls');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(n) { return css.getPropertyValue(n).trim(); }
var SKY0 = v('--sky-0'), SKY1 = v('--sky-1');
var GLOW0 = v('--glow-0'), GLOW1 = v('--glow-1');
var RIDGE_FAR = v('--ridge-far'), RIDGE_NEAR = v('--ridge-near'), TREE = v('--tree');
var CLOUD = v('--cloud');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var STACK = 12; // 겹치는 횟수 — 늘리면 광선이 더 길게 자란다
var GROW = 1.03; // 한 겹이 광원 기준으로 자라는 배율
var LERP = 0.05; // 광원이 목표를 따라오는 비율(프레임당) — 느리게 따라와 빛이 흐르는 느낌
var TOUR = 7; // 자율 광원이 한 바퀴 도는 초
var raf = 0, W = 0, H = 0, dpr = 1;
var lx = 0, ly = 0, tx = 0, ty = 0, manual = false;
var scene = document.createElement('canvas');
var sctx = scene.getContext('2d');
var veil = document.createElement('canvas');
var vctx = veil.getContext('2d');
// 시드 고정 난수 — 새로고침해도 같은 능선·나무가 자란다
function rng(seed) {
return function () { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
}
// 능선 점렬(정규화 0..1) — 조절점 사이를 코사인으로 부드럽게 잇는 값 노이즈. 양 끝이 이어진다
function ridgePts(seed, bumps) {
var rr = rng(seed);
var cp = [];
for (var i = 0; i < bumps; i++) cp.push(rr());
cp.push(cp[0]);
var pts = [];
var n = bumps * 12;
for (var k = 0; k <= n; k++) {
var t = k / n * bumps;
var i0 = Math.floor(t), f = t - i0;
var s = (1 - Math.cos(f * Math.PI)) * 0.5;
pts.push(cp[i0] + (cp[i0 + 1] - cp[i0]) * s);
}
return pts;
}
var farPts = ridgePts(11, 3);
var nearPts = ridgePts(77, 5);
var trees = [];
(function () {
var rr = rng(5);
for (var i = 0; i < 16; i++) trees.push({ x: rr(), h: 0.06 + rr() * 0.05 });
})();
var clouds = [];
(function () {
var rr = rng(23);
for (var i = 0; i < 3; i++) clouds.push({ x: 0.08 + rr() * 0.78, y: 0.09 + rr() * 0.17, w: 0.2 + rr() * 0.16, h: 0.026 + rr() * 0.02 });
})();
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = scene.width = veil.width = Math.round(W * dpr);
cv.height = scene.height = veil.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
vctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!lx) { lx = W * 0.34; ly = H * 0.26; tx = lx; ty = ly; }
}
// 능선 위 한 점의 y — 정규화 점렬을 CSS px 로 샘플한다
function ridgeY(pts, base, amp, x) {
var t = x / W * (pts.length - 1);
var i0 = Math.floor(t);
if (i0 >= pts.length - 1) return H * (base - amp * pts[pts.length - 1]);
var f = t - i0;
return H * (base - amp * (pts[i0] + (pts[i0 + 1] - pts[i0]) * f));
}
function fillRidge(c, pts, base, amp, color) {
c.fillStyle = color;
c.beginPath();
c.moveTo(0, H + 2);
for (var x = 0; x <= W; x += 4) c.lineTo(x, ridgeY(pts, base, amp, x));
c.lineTo(W, H + 2);
c.closePath();
c.fill();
}
// 가늘고 긴 구름 세 조각 — 하늘의 가림막. 광원과 구름 사이 틈이 하늘의 광선 뼈대다
function drawClouds(c) {
c.fillStyle = CLOUD;
for (var i = 0; i < clouds.length; i++) {
var cl = clouds[i];
var cx = cl.x * W, cy = cl.y * H, cw = cl.w * W, ch = cl.h * H;
c.beginPath();
c.moveTo(cx + cw * 0.5, cy);
c.ellipse(cx, cy, cw * 0.5, ch, 0, 0, Math.PI * 2);
c.moveTo(cx - cw * 0.32 + cw * 0.22, cy + ch * 0.35);
c.ellipse(cx - cw * 0.32, cy + ch * 0.35, cw * 0.22, ch * 0.62, 0, 0, Math.PI * 2);
c.moveTo(cx + cw * 0.3 + cw * 0.2, cy - ch * 0.3);
c.ellipse(cx + cw * 0.3, cy - ch * 0.3, cw * 0.2, ch * 0.6, 0, 0, Math.PI * 2);
c.fill();
}
}
// 침엽수 실루엣 — 앞 능선 등선선 위에 삼각 두 개를 포개 얹는다. 틈이 벌어질수록 광선이 굵어진다
function drawTrees(c) {
c.fillStyle = TREE;
for (var i = 0; i < trees.length; i++) {
var tr = trees[i];
var x = tr.x * W;
var y = ridgeY(nearPts, 0.80, 0.15, x) + 1;
var h = tr.h * H;
var w = h * 0.44;
c.beginPath();
c.moveTo(x, y - h);
c.lineTo(x + w * 0.5, y - h * 0.34);
c.lineTo(x + w * 0.28, y - h * 0.34);
c.lineTo(x + w * 0.85, y);
c.lineTo(x - w * 0.85, y);
c.lineTo(x - w * 0.28, y - h * 0.34);
c.lineTo(x - w * 0.5, y - h * 0.34);
c.closePath();
c.fill();
}
}
// 밑그림 — 세로 그라디언트 하늘 + 광원 주위 광운 + 실루엣 두 겹
function drawScene() {
var sky = sctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, SKY0);
sky.addColorStop(1, SKY1);
sctx.fillStyle = sky;
sctx.fillRect(0, 0, W, H);
var glow = sctx.createRadialGradient(lx, ly, 2, lx, ly, Math.max(W, H) * 0.26);
glow.addColorStop(0, GLOW0);
glow.addColorStop(0.22, GLOW0);
glow.addColorStop(1, GLOW1);
sctx.fillStyle = glow;
sctx.fillRect(0, 0, W, H);
drawClouds(sctx);
fillRidge(sctx, farPts, 0.58, 0.09, RIDGE_FAR);
fillRidge(sctx, nearPts, 0.80, 0.15, RIDGE_NEAR);
drawTrees(sctx);
}
// 베일 — 밑그림을 복사한 뒤 광원에서 장면 반대편으로 빠지는 선형 그라디언트로 알파를 깎는다
function drawVeil() {
vctx.globalCompositeOperation = 'source-over';
vctx.clearRect(0, 0, W, H);
vctx.drawImage(scene, 0, 0, W, H);
var ux = W * 0.5 - lx, uy = H * 0.5 - ly;
var ul = Math.sqrt(ux * ux + uy * uy) || 1;
var far = Math.sqrt(W * W + H * H) * 1.05;
var fx = lx + ux / ul * far, fy = ly + uy / ul * far;
var g = vctx.createLinearGradient(lx, ly, fx, fy);
g.addColorStop(0, 'rgba(0, 0, 0, .95)');
g.addColorStop(0.5, 'rgba(0, 0, 0, .5)');
g.addColorStop(1, 'rgba(0, 0, 0, 0)');
vctx.globalCompositeOperation = 'destination-in';
vctx.fillStyle = g;
vctx.fillRect(0, 0, W, H);
}
function paint() {
drawScene();
drawVeil();
// ① 밑그림을 그대로 깐다
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.drawImage(scene, 0, 0, W, H);
// ② 광원 기준 1.03배씩 커지는 겹 12장 — 겹칠수록 밝아지는 lighter 합성으로 광선이 자란다
var s = 1;
for (var i = 1; i <= STACK; i++) {
s *= GROW;
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = 0.55 * (1 - i / (STACK + 1));
ctx.drawImage(veil, lx * (1 - s), ly * (1 - s), W * s, H * s);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function frame(now) {
var sec = now / 1000;
if (!manual) {
var th = sec / TOUR * Math.PI * 2;
tx = W * (0.34 + Math.sin(th) * 0.18);
ty = H * (0.25 + Math.sin(th * 2 + 0.8) * 0.12);
}
lx += (tx - lx) * LERP;
ly += (ty - ly) * LERP;
paint();
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
tx = Math.max(W * 0.08, Math.min(W * 0.92, e.clientX - r.left));
ty = Math.max(H * 0.06, Math.min(H * 0.6, e.clientY - r.top));
manual = true;
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-12, 0], ArrowRight: [12, 0], ArrowUp: [0, -12], ArrowDown: [0, 12] }[e.key];
if (!d) { if (e.key === 'Escape') manual = false; return; }
e.preventDefault();
manual = true;
tx = Math.max(W * 0.08, Math.min(W * 0.92, tx + d[0]));
ty = Math.max(H * 0.06, Math.min(H * 0.6, ty + d[1]));
if (reduce) { lx = tx; ly = ty; paint(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) paint(); });
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 광선이 다 자란 정적 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint();
} else {
raf = requestAnimationFrame(frame);
}// LightScatter — vanilla 판(run/077/demo/08-fx-light-scatter/index.html) 의 React 포트.
// 밑그림(그라디언트 하늘 + 실루엣 능선·나무)을 광원 기준으로 매번 1.03배씩 키우며
// lighter 합성으로 열두 번 겹쳐 더해 부챗살 광선을 자라게 한다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LightScatter.module.scss";
export type LightScatterProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number };
const STACK = 12; // 겹치는 횟수 — 늘리면 광선이 더 길게 자란다
const GROW = 1.03; // 한 겹이 광원 기준으로 자라는 배율
const LERP = 0.05; // 광원이 목표를 따라오는 비율(프레임당)
const TOUR = 7; // 자율 광원이 한 바퀴 도는 초
const FIELD_LABEL = "빛 산란 실험판";
type Size = { w: number; h: number; dpr: number };
type Light = { lx: number; ly: number; tx: number; ty: number; manual: boolean };
// 시드 고정 난수와 능선 점렬(정규화 0..1) — 조절점 사이 코사인 보간, 양 끝이 이어진다
function rng(seed: number): () => number {
return function (): number {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
}
function ridgePts(seed: number, bumps: number): number[] {
const rr = rng(seed);
const cp: number[] = [];
for (let i = 0; i < bumps; i++) cp.push(rr());
cp.push(cp[0]);
const pts: number[] = [];
const n = bumps * 12;
for (let k = 0; k <= n; k++) {
const t = k / n * bumps;
const i0 = Math.floor(t), f = t - i0;
const s = (1 - Math.cos(f * Math.PI)) * 0.5;
pts.push(cp[i0] + (cp[i0 + 1] - cp[i0]) * s);
}
return pts;
}
const CLOUDS: { x: number; y: number; w: number; h: number }[] = [];
{
const rr = rng(23);
for (let i = 0; i < 3; i++) CLOUDS.push({ x: 0.08 + rr() * 0.78, y: 0.09 + rr() * 0.17, w: 0.2 + rr() * 0.16, h: 0.026 + rr() * 0.02 });
}
const FAR_PTS = ridgePts(11, 3);
const NEAR_PTS = ridgePts(77, 5);
const TREES: { x: number; h: number }[] = [];
{
const rr = rng(5);
for (let i = 0; i < 16; i++) TREES.push({ x: rr(), h: 0.06 + rr() * 0.05 });
}
export default function LightScatter({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#fff7e6", // vanilla $color: $subject-cream
}: LightScatterProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sceneRef = useRef<HTMLCanvasElement | null>(null);
const sctxRef = useRef<CanvasRenderingContext2D | null>(null);
const veilRef = useRef<HTMLCanvasElement | null>(null);
const vctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0, dpr: 1 }) as Box<Size>;
const lightRef = useRef({ lx: 0, ly: 0, tx: 0, ty: 0, manual: false }) as Box<Light>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const scene = sceneRef.current;
const veil = veilRef.current;
if (!field || !cv || !scene || !veil) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const s = sizeRef.current;
s.w = r.width; s.h = r.height; s.dpr = dpr;
cv.width = scene.width = veil.width = Math.round(r.width * dpr);
cv.height = scene.height = veil.height = Math.round(r.height * dpr);
ctxRef.current?.setTransform(dpr, 0, 0, dpr, 0, 0);
sctxRef.current?.setTransform(dpr, 0, 0, dpr, 0, 0);
vctxRef.current?.setTransform(dpr, 0, 0, dpr, 0, 0);
const li = lightRef.current;
if (!li.lx) { li.lx = r.width * 0.34; li.ly = r.height * 0.26; li.tx = li.lx; li.ty = li.ly; }
}
// 능선 위 한 점의 y — 정규화 점렬을 CSS px 로 샘플한다
function ridgeY(pts: number[], base: number, amp: number, x: number): number {
const s = sizeRef.current;
if (!s.w) return 0;
const t = x / s.w * (pts.length - 1);
const i0 = Math.floor(t);
if (i0 >= pts.length - 1) return s.h * (base - amp * pts[pts.length - 1]);
const f = t - i0;
return s.h * (base - amp * (pts[i0] + (pts[i0 + 1] - pts[i0]) * f));
}
function fillRidge(c: CanvasRenderingContext2D, pts: number[], base: number, amp: number, color: string): void {
const s = sizeRef.current;
c.fillStyle = color;
c.beginPath();
c.moveTo(0, s.h + 2);
for (let x = 0; x <= s.w; x += 4) c.lineTo(x, ridgeY(pts, base, amp, x));
c.lineTo(s.w, s.h + 2);
c.closePath();
c.fill();
}
// 가늘고 긴 구름 세 조각 — 하늘의 가림막. 광원과 구름 사이 틈이 하늘의 광선 뼈대다
function drawClouds(c: CanvasRenderingContext2D): void {
const s = sizeRef.current;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
c.fillStyle = cssv("--cloud");
for (const cl of CLOUDS) {
const cx = cl.x * s.w, cy = cl.y * s.h, cw = cl.w * s.w, ch = cl.h * s.h;
c.beginPath();
c.moveTo(cx + cw * 0.5, cy);
c.ellipse(cx, cy, cw * 0.5, ch, 0, 0, Math.PI * 2);
c.moveTo(cx - cw * 0.32 + cw * 0.22, cy + ch * 0.35);
c.ellipse(cx - cw * 0.32, cy + ch * 0.35, cw * 0.22, ch * 0.62, 0, 0, Math.PI * 2);
c.moveTo(cx + cw * 0.3 + cw * 0.2, cy - ch * 0.3);
c.ellipse(cx + cw * 0.3, cy - ch * 0.3, cw * 0.2, ch * 0.6, 0, 0, Math.PI * 2);
c.fill();
}
}
// 침엽수 실루엣 — 앞 능선 등선선 위에 삼각 두 개를 포개 얹는다
function drawTrees(c: CanvasRenderingContext2D): void {
const s = sizeRef.current;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
c.fillStyle = cssv("--tree");
for (const tr of TREES) {
const x = tr.x * s.w;
const y = ridgeY(NEAR_PTS, 0.80, 0.15, x) + 1;
const h = tr.h * s.h;
const w = h * 0.44;
c.beginPath();
c.moveTo(x, y - h);
c.lineTo(x + w * 0.5, y - h * 0.34);
c.lineTo(x + w * 0.28, y - h * 0.34);
c.lineTo(x + w * 0.85, y);
c.lineTo(x - w * 0.85, y);
c.lineTo(x - w * 0.28, y - h * 0.34);
c.lineTo(x - w * 0.5, y - h * 0.34);
c.closePath();
c.fill();
}
}
// 밑그림 — 세로 그라디언트 하늘 + 광원 주위 광운 + 실루엣 두 겹
function drawScene(): void {
const sctx = sctxRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!sctx || !s.w) return;
const cssv = (n: string) => getComputedStyle(rootRef.current as HTMLDivElement).getPropertyValue(n).trim();
const sky = sctx.createLinearGradient(0, 0, 0, s.h);
sky.addColorStop(0, cssv("--sky-0"));
sky.addColorStop(1, cssv("--sky-1"));
sctx.fillStyle = sky;
sctx.fillRect(0, 0, s.w, s.h);
const glow = sctx.createRadialGradient(li.lx, li.ly, 2, li.lx, li.ly, Math.max(s.w, s.h) * 0.26);
glow.addColorStop(0, cssv("--glow-0"));
glow.addColorStop(0.22, cssv("--glow-0"));
glow.addColorStop(1, cssv("--glow-1"));
sctx.fillStyle = glow;
sctx.fillRect(0, 0, s.w, s.h);
drawClouds(sctx);
fillRidge(sctx, FAR_PTS, 0.58, 0.09, cssv("--ridge-far"));
fillRidge(sctx, NEAR_PTS, 0.80, 0.15, cssv("--ridge-near"));
drawTrees(sctx);
}
// 베일 — 밑그림을 복사한 뒤 광원에서 장면 반대편으로 빠지는 선형 그라디언트로 알파를 깎는다
function drawVeil(): void {
const vctx = vctxRef.current;
const scene = sceneRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!vctx || !scene || !s.w) return;
vctx.globalCompositeOperation = "source-over";
vctx.clearRect(0, 0, s.w, s.h);
vctx.drawImage(scene, 0, 0, s.w, s.h);
const ux = s.w * 0.5 - li.lx, uy = s.h * 0.5 - li.ly;
const ul = Math.sqrt(ux * ux + uy * uy) || 1;
const far = Math.sqrt(s.w * s.w + s.h * s.h) * 1.05;
const fx = li.lx + ux / ul * far, fy = li.ly + uy / ul * far;
const g = vctx.createLinearGradient(li.lx, li.ly, fx, fy);
g.addColorStop(0, "rgba(0, 0, 0, .95)");
g.addColorStop(0.5, "rgba(0, 0, 0, .5)");
g.addColorStop(1, "rgba(0, 0, 0, 0)");
vctx.globalCompositeOperation = "destination-in";
vctx.fillStyle = g;
vctx.fillRect(0, 0, s.w, s.h);
}
function paint(): void {
const ctx = ctxRef.current;
const scene = sceneRef.current;
const veil = veilRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!ctx || !scene || !veil || !s.w) return;
drawScene();
drawVeil();
// ① 밑그림을 그대로 깐다
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.drawImage(scene, 0, 0, s.w, s.h);
// ② 광원 기준 1.03배씩 커지는 겹 12장 — 겹칠수록 밝아지는 lighter 합성으로 광선이 자란다
let sc = 1;
for (let i = 1; i <= STACK; i++) {
sc *= GROW;
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = 0.55 * (1 - i / (STACK + 1));
ctx.drawImage(veil, li.lx * (1 - sc), li.ly * (1 - sc), s.w * sc, s.h * sc);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
const s = sizeRef.current;
const li = lightRef.current;
if (!li.manual) {
const th = now / 1000 / TOUR * Math.PI * 2;
li.tx = s.w * (0.34 + Math.sin(th) * 0.18);
li.ty = s.h * (0.25 + Math.sin(th * 2 + 0.8) * 0.12);
}
li.lx += (li.tx - li.lx) * LERP;
li.ly += (li.ty - li.ly) * LERP;
paint();
rafRef.current = requestAnimationFrame(frame);
}
function onMove(e: PtrEvt): void {
const field = fieldRef.current;
const s = sizeRef.current;
const li = lightRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
li.tx = Math.max(s.w * 0.08, Math.min(s.w * 0.92, e.clientX - r.left));
li.ty = Math.max(s.h * 0.06, Math.min(s.h * 0.6, e.clientY - r.top));
li.manual = true;
}
function onLeave(): void {
lightRef.current.manual = false;
}
function onKey(e: KeyEvt): void {
const s = sizeRef.current;
const li = lightRef.current;
const map: Record<string, number[]> = {
ArrowLeft: [-12, 0], ArrowRight: [12, 0], ArrowUp: [0, -12], ArrowDown: [0, 12],
};
const dd = map[e.key];
if (!dd) { if (e.key === "Escape") li.manual = false; return; }
e.preventDefault();
li.manual = true;
li.tx = Math.max(s.w * 0.08, Math.min(s.w * 0.92, li.tx + dd[0]));
li.ty = Math.max(s.h * 0.06, Math.min(s.h * 0.6, li.ty + dd[1]));
if (reduced()) { li.lx = li.tx; li.ly = li.ty; paint(); }
}
function onResize(): void {
fit();
if (reduced()) paint();
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
const scene = document.createElement("canvas");
sceneRef.current = scene;
sctxRef.current = scene.getContext("2d");
const veil = document.createElement("canvas");
veilRef.current = veil;
vctxRef.current = veil.getContext("2d");
if (!ctxRef.current || !sctxRef.current || !vctxRef.current) return;
fit();
if (reduced()) {
// reduce — 루프를 시작하지 않고 광선이 다 자란 정적 한 장만 그린다
rootRef.current?.classList.add("is-still");
paint();
} else {
rafRef.current = requestAnimationFrame(frame);
}
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M184 128a56 56 0 1 1-56-56a56 56 0 0 1 56 56" opacity=".2"/><path d="M120 40V16a8 8 0 0 1 16 0v24a8 8 0 0 1-16 0m72 88a64 64 0 1 1-64-64a64.07 64.07 0 0 1 64 64m-16 0a48 48 0 1 0-48 48a48.05 48.05 0 0 0 48-48M58.34 69.66a8 8 0 0 0 11.32-11.32l-16-16a8 8 0 0 0-11.32 11.32Zm0 116.68l-16 16a8 8 0 0 0 11.32 11.32l16-16a8 8 0 0 0-11.32-11.32M192 72a8 8 0 0 0 5.66-2.34l16-16a8 8 0 0 0-11.32-11.32l-16 16A8 8 0 0 0 192 72m5.66 114.34a8 8 0 0 0-11.32 11.32l16 16a8 8 0 0 0 11.32-11.32ZM48 128a8 8 0 0 0-8-8H16a8 8 0 0 0 0 16h24a8 8 0 0 0 8-8m80 80a8 8 0 0 0-8 8v24a8 8 0 0 0 16 0v-24a8 8 0 0 0-8-8m112-88h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16"/></g></svg>
<span>커서를 움직이면 광원이 천천히 따라와 빛이 부챗살로 갈라집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onMove}
onPointerLeave={onLeave}
onKeyDown={onKey}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum}>12</span><span className={styles.badgeUnit}>겹 누적</span></b>
</div>
</div>
</main>
);
}08 Scattered light rays — Measured area 46.398% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · HTML5 Canvas Animation: 9 Warp & Droplet Effects
The post this demo lives in
HTML5 Canvas Animation: 9 Warp & Droplet EffectsHTML5 canvas animation bends pixels — a droplet swelling the text under it, a black hole pulling the screen in — no reto See all demos in this post
If you like this effect - similar demos
Creative UI06 Murmuration — a black cloud that leaves flow lines9 Canvas Boids Algorithm Flocking Birds Simulation
Creative UI05 Aurora curtain — vertical noise light veil9 Perlin Noise Animations, No Libraries, Pure Math
Creative UI02 Pen, highlighter, eraser toolbarSignature Pad JavaScript: 9 Canvas Drawing UIs