transition curtain wipe page-transition navigation entrance
Page Curtain
page-curtain.js
/**
* Page Curtain — Full-viewport wipe transition. Slides in from bottom, holds, slides out to top.
* Category: decorative | Animate: true | Interactive: false | Complexity: low
*/
export default function pageCurtain(ctx, _state) {
const DURATION_IN = 0.5;
const DURATION_HOLD = 0.4;
const DURATION_OUT = 0.5;
const DURATION_PAUSE = 0.9;
const CYCLE = DURATION_IN + DURATION_HOLD + DURATION_OUT + DURATION_PAUSE;
function easeInOutExpo(t) {
if (t === 0) return 0;
if (t === 1) return 1;
return t < 0.5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (-20 * t + 10)) / 2;
}
return {
frame({ width, height, time, reducedMotion }) {
ctx.clearRect(0, 0, width, height);
if (reducedMotion) return;
const t = time % CYCLE;
let progress = 0;
if (t < DURATION_IN) {
progress = easeInOutExpo(t / DURATION_IN);
} else if (t < DURATION_IN + DURATION_HOLD) {
progress = 1;
} else if (t < DURATION_IN + DURATION_HOLD + DURATION_OUT) {
const tt = (t - DURATION_IN - DURATION_HOLD) / DURATION_OUT;
progress = 1 - easeInOutExpo(tt);
}
if (progress <= 0.001) return;
const curtainH = height * progress;
const y = height - curtainH;
// Curtain body
const grad = ctx.createLinearGradient(0, y, 0, height);
grad.addColorStop(0, 'oklch(20% 0.015 75)');
grad.addColorStop(0.4, 'oklch(14% 0.01 75)');
grad.addColorStop(1, 'oklch(10% 0.008 75)');
ctx.fillStyle = grad;
ctx.fillRect(0, y, width, curtainH);
// Leading-edge highlight
ctx.fillStyle = 'oklch(32% 0.02 75)';
ctx.fillRect(0, y, width, Math.min(3, curtainH));
// Subtle horizontal texture lines
ctx.strokeStyle = 'oklch(24% 0.012 75)';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.35;
for (let i = 1; i < 4; i++) {
const ly = y + (curtainH * i) / 4;
ctx.beginPath();
ctx.moveTo(0, ly);
ctx.lineTo(width, ly);
ctx.stroke();
}
ctx.globalAlpha = 1;
},
destroy() {},
};
}
embed.html
<canvas id="canvas-effect" style="width:100%;height:400px;display:block" aria-hidden="true"></canvas>
<script>
/**
* mountCanvas — Minimal runtime for Canvas effects.
*
* Handles HiDPI scaling, ResizeObserver, RAF loop, pointer tracking,
* reduced-motion, and cleanup. The effect only implements draw logic.
*
* @param {HTMLCanvasElement} canvas
* @param {(ctx: CanvasRenderingContext2D, state: object) => object} createEffect
* @param {object} [options]
* @returns {() => void} destroy function
*/
function mountCanvas(canvas, createEffect, options = {}) {
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) throw new Error('2D context not available');
const opts = {
animate: true,
clear: true,
maxDpr: 2,
pauseWhenHidden: true,
respectReducedMotion: true,
...options,
};
const reducedMotion =
opts.respectReducedMotion && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const state = {
canvas,
ctx,
width: 0,
height: 0,
pixelWidth: 0,
pixelHeight: 0,
dpr: 1,
time: 0,
delta: 0,
frame: 0,
playing: false,
reducedMotion,
pointer: { x: 0, y: 0, inside: false, down: false },
};
let rafId = 0;
let lastTime = 0;
let disposed = false;
function measure() {
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, rect.width);
const h = Math.max(1, rect.height);
const dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr);
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
state.width = w;
state.height = h;
state.pixelWidth = pw;
state.pixelHeight = ph;
state.dpr = dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clear() {
ctx.clearRect(0, 0, state.width, state.height);
}
const effect = createEffect(ctx, state) || {};
function onResize() {
measure();
effect.resize?.(state);
if (!opts.animate || reducedMotion) {
if (opts.clear) clear();
effect.frame?.(state);
}
}
function loop(now) {
if (disposed) return;
if (opts.pauseWhenHidden && document.hidden) {
rafId = requestAnimationFrame(loop);
return;
}
state.delta = lastTime ? (now - lastTime) / 1000 : 0;
state.time = now / 1000;
state.frame += 1;
lastTime = now;
if (opts.clear) clear();
effect.frame?.(state);
rafId = requestAnimationFrame(loop);
}
function onPointerMove(e) {
const rect = canvas.getBoundingClientRect();
state.pointer.x = e.clientX - rect.left;
state.pointer.y = e.clientY - rect.top;
if (!opts.animate || reducedMotion) {
if (opts.clear) clear();
effect.frame?.(state);
}
}
function onPointerEnter() {
state.pointer.inside = true;
}
function onPointerLeave() {
state.pointer.inside = false;
state.pointer.down = false;
}
function onPointerDown() {
state.pointer.down = true;
}
function onPointerUp() {
state.pointer.down = false;
}
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerenter', onPointerEnter);
canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerUp);
measure();
if (opts.animate && !reducedMotion) {
state.playing = true;
rafId = requestAnimationFrame(loop);
} else {
effect.frame?.(state);
}
return function destroy() {
disposed = true;
state.playing = false;
cancelAnimationFrame(rafId);
ro.disconnect();
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerenter', onPointerEnter);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
effect.destroy?.(state);
};
}
/**
* Page Curtain — Full-viewport wipe transition. Slides in from bottom, holds, slides out to top.
* Category: decorative | Animate: true | Interactive: false | Complexity: low
*/
const __effect = function pageCurtain(ctx, _state) {
const DURATION_IN = 0.5;
const DURATION_HOLD = 0.4;
const DURATION_OUT = 0.5;
const DURATION_PAUSE = 0.9;
const CYCLE = DURATION_IN + DURATION_HOLD + DURATION_OUT + DURATION_PAUSE;
function easeInOutExpo(t) {
if (t === 0) return 0;
if (t === 1) return 1;
return t < 0.5 ? 2 ** (20 * t - 10) / 2 : (2 - 2 ** (-20 * t + 10)) / 2;
}
return {
frame({ width, height, time, reducedMotion }) {
ctx.clearRect(0, 0, width, height);
if (reducedMotion) return;
const t = time % CYCLE;
let progress = 0;
if (t < DURATION_IN) {
progress = easeInOutExpo(t / DURATION_IN);
} else if (t < DURATION_IN + DURATION_HOLD) {
progress = 1;
} else if (t < DURATION_IN + DURATION_HOLD + DURATION_OUT) {
const tt = (t - DURATION_IN - DURATION_HOLD) / DURATION_OUT;
progress = 1 - easeInOutExpo(tt);
}
if (progress <= 0.001) return;
const curtainH = height * progress;
const y = height - curtainH;
// Curtain body
const grad = ctx.createLinearGradient(0, y, 0, height);
grad.addColorStop(0, 'oklch(20% 0.015 75)');
grad.addColorStop(0.4, 'oklch(14% 0.01 75)');
grad.addColorStop(1, 'oklch(10% 0.008 75)');
ctx.fillStyle = grad;
ctx.fillRect(0, y, width, curtainH);
// Leading-edge highlight
ctx.fillStyle = 'oklch(32% 0.02 75)';
ctx.fillRect(0, y, width, Math.min(3, curtainH));
// Subtle horizontal texture lines
ctx.strokeStyle = 'oklch(24% 0.012 75)';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.35;
for (let i = 1; i < 4; i++) {
const ly = y + (curtainH * i) / 4;
ctx.beginPath();
ctx.moveTo(0, ly);
ctx.lineTo(width, ly);
ctx.stroke();
}
ctx.globalAlpha = 1;
},
destroy() {},
};
}
const canvas = document.getElementById('canvas-effect');
mountCanvas(canvas, __effect, { animate: true, clear: true });
</script> Details
Animated Reduced Motion aria-hidden
transitioncurtainwipepage-transitionnavigationentrance
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| DURATION_IN | number | 0.5 | Seconds for the curtain to slide in |
| DURATION_OUT | number | 0.5 | Seconds for the curtain to slide out |
| DURATION_HOLD | number | 0.4 | Seconds the curtain holds at full coverage |
The curtain slides in from the bottom on an exponential ease, holds briefly at full coverage, then wipes out upward. The warm sand gradient and subtle texture lines give it depth without distraction. At prefers-reduced-motion: reduce, the canvas stays empty — the transition simply doesn’t play.
In production, trigger the animation by adding the canvas as a fixed overlay and driving the effect via a custom state machine or Astro’s astro:before-preparation / astro:after-swap lifecycle hooks.