06Card Shadow Values Computed From the Elevation Level
One elevation number yields the offset, blur and opacity by formula, each card gets its own shadow, and the same values drop straight into a box-shadow.
Best forDashboard panels and stacked cards
ELEV_STEPSHADOW_ALPHA_BASEHOVER_BONUS

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="One elevation number yields the offset, blur and opacity by formula, each card gets its own shadow, and the same values drop straight into a box-shadow."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Demo running</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">One elevation number yields the offset, blur and opacity by formula, each card gets its own shadow, and the same values drop straight into a box-shadow.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { cursor: default; }
}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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const ELEV_STEP = 4;
const SHADOW_ALPHA_BASE = 0.12;
const CARD_COUNT = 5;
const CARD_W = 68;
const CARD_H = 124;
const CARD_Y = 98;
const HOVER_BONUS = 3;
const LIGHT_LERP = 0.16;
const LIGHT_HEIGHT = 150;
const FLOOR_RGB = [244, 238, 252];
const INK_RGB = [70, 50, 116];
const CARD_RGBS = [[255, 226, 214], [255, 240, 196], [214, 244, 226], [206, 232, 255], [232, 218, 255]];
// 층 번호 하나에서 그림자 값 하나를 계산한다 — 오프셋 · 번짐 · 농도
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)
};
}
function makeScene(ctx) {
let lx = 60;
let ly = 40;
const bonus = [];
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus.push(0);
}
function cardX(i) { return 62 + i * 88; }
function nearest() {
let best = 0;
for (let i = 1; i < CARD_COUNT; i += 1) {
if (Math.abs(cardX(i) + CARD_W / 2 - lx) < Math.abs(cardX(best) + CARD_W / 2 - lx)) {
best = i;
}
}
return best;
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 210 * Math.sin(a), 52 + 30 * Math.sin(a * 2)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus[i] = lerp(bonus[i], i === n ? HOVER_BONUS : 0, 0.14);
}
},
snap: function (tx, ty) {
lx = tx;
ly = ty;
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus[i] = i === n ? HOVER_BONUS : 0;
}
},
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 260, [255, 226, 160], 0.85);
for (let i = 0; i < CARD_COUNT; i += 1) {
const level = i + 1 + bonus[i];
const x = cardX(i);
const y = CARD_Y - bonus[i] * 3;
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);
ctx.shadowColor = rgba(INK_RGB, s.alpha * 0.7);
ctx.shadowBlur = s.blur * 0.4;
ctx.shadowOffsetX = s.ox * 0.35;
ctx.shadowOffsetY = s.oy * 0.35;
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = 'rgba(0,0,0,0)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = rgba(INK_RGB, 0.72);
ctx.font = '600 14px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('E' + String(i + 1), x + CARD_W / 2, y + 26);
ctx.font = '500 10px ' + FONT;
ctx.fillText(String(Math.round(s.blur)) + 'px', x + CARD_W / 2, y + 44);
}
glow(ctx, lx, ly, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = 'Stepping one scene at a time';
btnEl.textContent = 'Next scene';
return;
}
statusEl.textContent = !autoplay ? 'Manual control' : (paused ? 'Paused' : 'Demo running');
btnEl.textContent = (autoplay && !paused) ? 'Pause' : 'Play';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastElevationTokenCardStack.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const ELEV_STEP = 4;
const SHADOW_ALPHA_BASE = 0.12;
const CARD_COUNT = 5;
const CARD_W = 68;
const CARD_H = 124;
const CARD_Y = 98;
const HOVER_BONUS = 3;
const LIGHT_LERP = 0.16;
const LIGHT_HEIGHT = 150;
const FLOOR_RGB = [244, 238, 252];
const INK_RGB = [70, 50, 116];
const CARD_RGBS = [[255, 226, 214], [255, 240, 196], [214, 244, 226], [206, 232, 255], [232, 218, 255]];
// 층 번호 하나에서 그림자 값 하나를 계산한다 — 오프셋 · 번짐 · 농도
function shadowOf(level: number, dx: number, dy: number): { ox: number; oy: number; blur: number; alpha: number } {
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)
};
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 60;
let ly = 40;
const bonus: number[] = [];
for (let i = 0; i < CARD_COUNT; i += 1) { bonus.push(0); }
function cardX(i: number): number { return 62 + i * 88; }
function nearest(): number {
let best = 0;
for (let i = 1; i < CARD_COUNT; i += 1) {
if (Math.abs(cardX(i) + CARD_W / 2 - lx) < Math.abs(cardX(best) + CARD_W / 2 - lx)) { best = i; }
}
return best;
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 210 * Math.sin(a), 52 + 30 * Math.sin(a * 2)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) { bonus[i] = lerp(bonus[i], i === n ? HOVER_BONUS : 0, 0.14); }
},
snap: function (tx: number, ty: number): void {
lx = tx;
ly = ty;
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) { bonus[i] = i === n ? HOVER_BONUS : 0; }
},
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 260, [255, 226, 160], 0.85);
for (let i = 0; i < CARD_COUNT; i += 1) {
const level = i + 1 + bonus[i];
const x = cardX(i);
const y = CARD_Y - bonus[i] * 3;
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);
ctx.shadowColor = rgba(INK_RGB, s.alpha * 0.7);
ctx.shadowBlur = s.blur * 0.4;
ctx.shadowOffsetX = s.ox * 0.35;
ctx.shadowOffsetY = s.oy * 0.35;
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = 'rgba(0,0,0,0)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = rgba(INK_RGB, 0.72);
ctx.font = '600 14px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('E' + String(i + 1), x + CARD_W / 2, y + 26);
ctx.font = '500 10px ' + FONT;
ctx.fillText(String(Math.round(s.blur)) + 'px', x + CARD_W / 2, y + 44);
}
glow(ctx, lx, ly, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastElevationTokenCardStack() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다.</p>
</div>
</main>
);
}06 Card Shadow Values Computed From the Elevation Level — Measured area 41.603% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Shadow Effects: 9 Demos That Compute the Cast
The post this demo lives in
CSS Shadow Effects: 9 Demos That Compute the CastWith css shadow effects from one box-shadow line, the shadow stays put as the light circles, light leaks behind walls an See all demos in this post
If you like this effect - similar demos
Creative UI02 Page Flip Whose Shadow Is Computed Along the Fold LinePage Turn Animation CSS — 9 Paper Curl Patterns
Creative UI01 Stacked box shadowCSS Generator: 9 Sliders That Write the Code
Creative UI04 Coral growth — the reaction front reaches outward from a seed9 Reaction Diffusion Simulations, No Library