05Scene That Keeps Drawing While the Trail Slowly Fades
Past frames are progressively re-tinted toward transparent each tick while the newest trace sits on top, producing a drawing scene where the pen's path thins out over time.
Best forContinuously moving section background
TRAIL_FADE_FRAMESFADE_ALPHAsimStep

<main class="stage">
<div class="sp5 is-demo" id="sp5Root" tabindex="0">
<canvas class="sp5__canvas" id="sp5Canvas" width="480" height="300"></canvas>
<p class="sp5__caption">Overwrites the previous frame's lines with a little more transparency each frame and layers the new trace on top, creating a drawing scene where the pen's path fades over time</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sp5 {
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: $stage-ink;
}
.sp5__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp5__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp5__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sp5 { 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);
}
.sp5 {
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;
}
.sp5__canvas {
display: block;
width: 100%;
height: 100%;
}
.sp5__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sp5__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp5 {
cursor: default;
}
}(function () {
// 기법: 고정 시간 스텝(STEP_MS)마다 캔버스 전체를 반투명 무대색으로 덮어써 지난 궤적을
// 서서히 지우고, 그 위에 새 구간만 불투명하게 얹는다. TRAIL_FADE_FRAMES 스텝이 지나면
// 한 지점의 흔적이 실질적으로 사라지도록 감쇠 알파를 그 값에서 역산한다.
var root = document.getElementById('sp5Root');
var canvas = document.getElementById('sp5Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_DIM = 200;
var GEAR_RING_TEETH = 54;
var GEAR_ROLL_TEETH = 20;
var PEN_TIP_RATIO = 0.6;
var TOOTH_UNIT = 0.9;
var TRAIL_FADE_FRAMES = 240;
var STEP_MS = 1000 / 60;
var ANGLE_SPEED = 4.2;
var FADE_ALPHA = Math.min(1, 3.0 / TRAIL_FADE_FRAMES);
var R = GEAR_RING_TEETH * TOOTH_UNIT;
var r = GEAR_ROLL_TEETH * TOOTH_UNIT;
var d = r * PEN_TIP_RATIO;
function point(t) {
var k = (R - r) / r;
return {
x: (R - r) * Math.cos(t) + d * Math.cos(k * t),
y: (R - r) * Math.sin(t) - d * Math.sin(k * t)
};
}
var cw = 480, ch = 300, scale = 1, cx = 240, cy = 150;
function fit() {
var rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas.width = cw; canvas.height = ch;
scale = Math.min(cw, ch) / CALC_DIM * 0.92;
cx = cw / 2; cy = ch / 2;
}
function toScreen(p) { return { x: cx + p.x * scale, y: cy + p.y * scale }; }
var t = 0;
var prev = null;
function resetCanvas() {
ctx.fillStyle = '#17141a';
ctx.fillRect(0, 0, cw, ch);
prev = toScreen(point(t));
}
function simStep() {
ctx.fillStyle = 'rgba(23,20,26,' + FADE_ALPHA + ')';
ctx.fillRect(0, 0, cw, ch);
t += ANGLE_SPEED * (STEP_MS / 1000);
var p = toScreen(point(t));
ctx.strokeStyle = 'rgba(255,247,230,.95)';
ctx.shadowColor = 'rgba(255,214,200,.7)';
ctx.shadowBlur = Math.max(8, scale * 0.14);
ctx.lineWidth = Math.max(3, scale * 0.045);
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.shadowBlur = 0;
var halo = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, Math.max(cw, ch) * 0.34);
halo.addColorStop(0, 'rgba(255,200,215,.075)');
halo.addColorStop(0.5, 'rgba(190,225,255,.04)');
halo.addColorStop(1, 'rgba(190,225,255,0)');
ctx.fillStyle = halo;
ctx.fillRect(0, 0, cw, ch);
prev = p;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
fit();
resetCanvas();
if (reduce) {
var warm = TRAIL_FADE_FRAMES * 2.4;
for (var w = 0; w < warm; w += 1) simStep();
} else {
var acc = 0;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += dt;
var guard = 0;
while (acc >= STEP_MS && guard < 6) {
simStep();
acc -= STEP_MS;
guard += 1;
}
requestAnimationFrame(tick);
}
window.addEventListener('resize', function () { fit(); resetCanvas(); });
requestAnimationFrame(tick);
}
})();import { useEffect, useRef } from "react";
import styles from "./SpiroSceneTrailFadeDraw.module.scss";
type Pt = { x: number; y: number };
export default function SpiroSceneTrailFadeDraw() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_DIM = 200;
const GEAR_RING_TEETH = 54;
const GEAR_ROLL_TEETH = 20;
const PEN_TIP_RATIO = 0.6;
const TOOTH_UNIT = 0.9;
const TRAIL_FADE_FRAMES = 240;
const STEP_MS = 1000 / 60;
const ANGLE_SPEED = 4.2;
const FADE_ALPHA = Math.min(1, 3.0 / TRAIL_FADE_FRAMES);
const R = GEAR_RING_TEETH * TOOTH_UNIT;
const r = GEAR_ROLL_TEETH * TOOTH_UNIT;
const d = r * PEN_TIP_RATIO;
function point(t: number): Pt {
const k = (R - r) / r;
return {
x: (R - r) * Math.cos(t) + d * Math.cos(k * t),
y: (R - r) * Math.sin(t) - d * Math.sin(k * t)
};
}
let cw = 480, ch = 300, scale = 1, cx = 240, cy = 150;
function fit() {
const rect = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(rect.width));
ch = Math.max(1, Math.round(rect.height));
canvas!.width = cw; canvas!.height = ch;
scale = Math.min(cw, ch) / CALC_DIM * 0.92;
cx = cw / 2; cy = ch / 2;
}
function toScreen(p: Pt): Pt { return { x: cx + p.x * scale, y: cy + p.y * scale }; }
let t = 0;
let prev: Pt = { x: cx, y: cy };
function resetCanvas() {
ctx!.fillStyle = "#17141a";
ctx!.fillRect(0, 0, cw, ch);
prev = toScreen(point(t));
}
function simStep() {
ctx!.fillStyle = "rgba(23,20,26," + FADE_ALPHA + ")";
ctx!.fillRect(0, 0, cw, ch);
t += ANGLE_SPEED * (STEP_MS / 1000);
const p = toScreen(point(t));
ctx!.strokeStyle = "rgba(255,247,230,.95)";
ctx!.shadowColor = 'rgba(255,214,200,.7)';
ctx!.shadowBlur = Math.max(8, scale * 0.14);
ctx!.lineWidth = Math.max(3, scale * 0.045);
ctx!.lineJoin = "round";
ctx!.lineCap = "round";
ctx!.beginPath();
ctx!.moveTo(prev.x, prev.y);
ctx!.lineTo(p.x, p.y);
ctx!.stroke();
ctx!.shadowBlur = 0;
const halo = ctx!.createRadialGradient(p.x, p.y, 0, p.x, p.y, Math.max(cw, ch) * 0.34);
halo.addColorStop(0, 'rgba(255,200,215,.075)');
halo.addColorStop(0.5, 'rgba(190,225,255,.04)');
halo.addColorStop(1, 'rgba(190,225,255,0)');
ctx!.fillStyle = halo;
ctx!.fillRect(0, 0, cw, ch);
prev = p;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
fit();
resetCanvas();
let raf = 0;
if (reduce) {
const warm = TRAIL_FADE_FRAMES * 2.4;
for (let w = 0; w < warm; w += 1) simStep();
} else {
let acc = 0;
let lastTs: number | null = null;
const onResize = () => { fit(); resetCanvas(); };
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += dt;
let guard = 0;
while (acc >= STEP_MS && guard < 6) {
simStep();
acc -= STEP_MS;
guard += 1;
}
raf = requestAnimationFrame(tick);
}
window.addEventListener("resize", onResize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}
return () => { cancelAnimationFrame(raf); };
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sp5} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.sp5__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.sp5__caption}>지난 프레임의 선을 프레임마다 조금씩 투명하게 덮어쓰고 새 궤적을 맨 앞에 얹어, 펜이 지나간 자리가 시간에 따라 옅어지는 그리기 장면을 만든다</p>
</div>
</main>
);
}05 Scene That Keeps Drawing While the Trail Slowly Fades — Measured area 49.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Spirograph Generator: 9 Gear-Ratio Curve Patterns
The post this demo lives in
Spirograph Generator: 9 Gear-Ratio Curve PatternsWhen the two gear tooth counts share no common factor the curve closes, and the moment the ratio slips the sheet wanders See all demos in this post
If you like this effect - similar demos
Creative UI09 A flock that spells — a sentence assembling while dodging the pointer9 Canvas Boids Algorithm Flocking Birds Simulation
Creative UI08 Strike flash — the whole scene cools off exponentially right after impact9 Lightning Effect Background Scenes, No WebGL
Creative UI03 Drawing a star — coefficients sorted by size, one stroke of the penFourier Epicycles, 9 Ways (No Library)