04Image Transition Where a Brightness-sort Progress Value Reverses
A brightness-sort progress value is computed to first show streaked bands of sorted pixels, and as that value drops the pixels return to their original spots to reveal the image.
Best forArtwork-load reveals and gallery-exhibit intros
sortedIdxbrightnessclampI
<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__hint"><span>Click or press Enter and the brightness-sort progress value drops as pixels return to their original places</span></p>
<div class="rv__panel" id="panel" tabindex="0" role="button" aria-label="Replay image transition reversed by brightness-sort progress value">
<canvas class="rv__cv" id="cv" aria-hidden="true"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(밝기 정렬 렌더)은 index.html 의 PRECOMPUTE()/RENDER() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rv {
--scene-bg1: #{$subject-ink};
--scene-bg2: #{$subject-blue};
--scene-blob1: #{$subject-pink};
--scene-blob2: #{$subject-cream};
--accent: #{$color};
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: $subject-ink;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba($subject-ink, .14);
transition: box-shadow $duration $easing;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px $color;
}
.rv__panel:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint { font-size: 11px; }
.rv__panel, .rv__cv { height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rv__panel { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
--scene-bg1: #17141a;
--scene-bg2: #2f6df6;
--scene-blob1: #ff9ebb;
--scene-blob2: #fff7e6;
--accent: #0a4fe8;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.rv__hint {
margin: 0;
color: #17141a;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.rv__panel {
position: relative;
width: 100%;
height: 176px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.14);
transition: box-shadow 2s ease-out;
}
.rv__panel:hover {
box-shadow: 0 0 0 1px #0a4fe8;
}
.rv__panel:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.rv__hint {
font-size: 11px;
}
.rv__panel, .rv__cv {
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv__panel {
transition: none;
}
}(function () {
var root = document.getElementById('rv');
var panel = document.getElementById('panel');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
// 결정론 난수 — Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var css = getComputedStyle(root);
function tok(name, fb) { var v = css.getPropertyValue(name); return v ? (v.trim() || fb) : fb; }
var BG1 = tok('--scene-bg1', '#17141a');
var BG2 = tok('--scene-bg2', '#2f6df6');
var BLOB1 = tok('--scene-blob1', '#ff9ebb');
var BLOB2 = tok('--scene-blob2', '#fff7e6');
var W = 0, H = 0, dpr = 1;
var buf = document.createElement('canvas');
var bctx = buf.getContext('2d');
// 장면(전시 갤러리 아트워크 자리) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bc, w, h, seed) {
var rng = makeLcg(seed);
var g = bc.createLinearGradient(0, 0, w, h);
g.addColorStop(0, BG1); g.addColorStop(1, BG2);
bc.fillStyle = g;
bc.fillRect(0, 0, w, h);
for (var i = 0; i < 7; i++) {
bc.fillStyle = i % 2 ? BLOB1 : BLOB2;
bc.beginPath();
bc.arc(rng() * w, rng() * h, w * (0.06 + rng() * 0.1), 0, Math.PI * 2);
bc.fill();
}
}
var SEED = 601;
var PLAY_MS = 680;
var data, outImg, iw, ih, sortedIdx;
function brightness(k) { return 0.299 * data[k] + 0.587 * data[k + 1] + 0.114 * data[k + 2]; }
function PRECOMPUTE() {
iw = cv.width; ih = cv.height;
data = bctx.getImageData(0, 0, iw, ih).data;
outImg = ctx.createImageData(iw, ih);
sortedIdx = [];
for (var y = 0; y < ih; y++) {
var row = new Int32Array(iw);
var order = [];
for (var x = 0; x < iw; x++) order.push(x);
var base = y * iw;
order.sort(function (a, b) { return brightness((base + a) * 4) - brightness((base + b) * 4); });
for (var j = 0; j < iw; j++) row[j] = order[j];
sortedIdx.push(row);
}
}
function clampI(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function RENDER(p) {
var o = outImg.data;
for (var y = 0; y < ih; y++) {
var row = sortedIdx[y];
var base = y * iw;
for (var x = 0; x < iw; x++) {
var si = row[x];
var sx = clampI(Math.round(x + (si - x) * p), 0, iw - 1);
var k = (base + x) * 4;
var sk = (base + sx) * 4;
o[k] = data[sk]; o[k + 1] = data[sk + 1]; o[k + 2] = data[sk + 2]; o[k + 3] = 255;
}
}
ctx.putImageData(outImg, 0, 0);
}
function fit() {
var r = panel.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
buf.width = cv.width; buf.height = cv.height;
paintScene(bctx, buf.width, buf.height, SEED);
PRECOMPUTE();
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var progress = 0, target = 0, playT0 = 0, playFrom = 0, playing = false;
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function play() {
human();
playFrom = progress; target = progress < 0.5 ? 1 : 0;
playT0 = 0; playing = true;
}
function stepPlay(now) {
if (!playing) return;
if (!playT0) playT0 = now;
var t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) { playing = false; progress = target; }
}
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
requestAnimationFrame(driveTick);
}
function frame(now) {
stepPlay(now);
RENDER(progress);
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
root.classList.add('is-still');
progress = 0; RENDER(progress);
panel.addEventListener('click', function () { human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); });
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); progress = progress < 0.5 ? 1 : 0; RENDER(progress); }
});
} else {
panel.addEventListener('click', play);
panel.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./PixelSortReveal.module.scss";
export default function PixelSortReveal() {
const rootRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const cv = cvRef.current;
if (!root || !panel || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
// 결정론 난수 — Math.random 을 쓰지 않는다. LCG(선형합동생성기) 하나로 장면을 만든다.
function makeLcg(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const css = getComputedStyle(root);
function tok(name: string, fb: string) {
const v = css.getPropertyValue(name);
return v ? v.trim() || fb : fb;
}
const BG1 = tok("--scene-bg1", "#17141a");
const BG2 = tok("--scene-bg2", "#2f6df6");
const BLOB1 = tok("--scene-blob1", "#ff9ebb");
const BLOB2 = tok("--scene-blob2", "#fff7e6");
let W = 0;
let H = 0;
let dpr = 1;
const buf = document.createElement("canvas");
const bctx = buf.getContext("2d");
if (!bctx) return;
// 장면(전시 갤러리 아트워크 자리) — 전부 캔버스로 직접 그린다. 외부 사진·파일 0.
function paintScene(bc: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rng = makeLcg(seed);
const g = bc.createLinearGradient(0, 0, w, h);
g.addColorStop(0, BG1);
g.addColorStop(1, BG2);
bc.fillStyle = g;
bc.fillRect(0, 0, w, h);
for (let i = 0; i < 7; i++) {
bc.fillStyle = i % 2 ? BLOB1 : BLOB2;
bc.beginPath();
bc.arc(rng() * w, rng() * h, w * (0.06 + rng() * 0.1), 0, Math.PI * 2);
bc.fill();
}
}
const SEED = 601;
const PLAY_MS = 680;
let data: Uint8ClampedArray | null = null;
let outImg: ImageData | null = null;
let iw = 0;
let ih = 0;
let sortedIdx: Int32Array[] = [];
function brightness(d: Uint8ClampedArray, k: number) {
return 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2];
}
function PRECOMPUTE() {
iw = cv!.width;
ih = cv!.height;
data = bctx!.getImageData(0, 0, iw, ih).data;
outImg = ctx!.createImageData(iw, ih);
const d = data!;
sortedIdx = [];
for (let y = 0; y < ih; y++) {
const row = new Int32Array(iw);
const order: number[] = [];
for (let x = 0; x < iw; x++) order.push(x);
const base = y * iw;
order.sort((a, b) => brightness(d, (base + a) * 4) - brightness(d, (base + b) * 4));
for (let j = 0; j < iw; j++) row[j] = order[j];
sortedIdx.push(row);
}
}
function clampI(v: number, lo: number, hi: number) {
return v < lo ? lo : v > hi ? hi : v;
}
function RENDER(p: number) {
const o = outImg!.data;
const d = data!;
for (let y = 0; y < ih; y++) {
const row = sortedIdx[y];
const base = y * iw;
for (let x = 0; x < iw; x++) {
const si = row[x];
const sx = clampI(Math.round(x + (si - x) * p), 0, iw - 1);
const k = (base + x) * 4;
const sk = (base + sx) * 4;
o[k] = d[sk];
o[k + 1] = d[sk + 1];
o[k + 2] = d[sk + 2];
o[k + 3] = 255;
}
}
ctx!.putImageData(outImg!, 0, 0);
}
function fit() {
const r = panel!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width;
H = r.height;
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
buf.width = cv!.width;
buf.height = cv!.height;
paintScene(bctx!, buf.width, buf.height, SEED);
PRECOMPUTE();
}
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
let progress = 0;
let target = 0;
let playT0 = 0;
let playFrom = 0;
let playing = false;
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function play() {
human();
playFrom = progress;
target = progress < 0.5 ? 1 : 0;
playT0 = 0;
playing = true;
}
function stepPlay(now: number) {
if (!playing) return;
if (!playT0) playT0 = now;
const t = Math.min(1, (now - playT0) / PLAY_MS);
progress = playFrom + (target - playFrom) * easeInOut(t);
if (t >= 1) {
playing = false;
progress = target;
}
}
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
let rafFrame = 0;
let rafDrive = 0;
function triangle(frac: number) {
return frac < 0.5 ? frac * 2 : 2 - frac * 2;
}
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
progress = triangle(frac);
}
rafDrive = requestAnimationFrame(driveTick);
}
function frame(now: number) {
stepPlay(now);
RENDER(progress);
rafFrame = requestAnimationFrame(frame);
}
function onClick() {
play();
}
function onKeyDown(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
play();
}
}
function onClickReduce() {
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
function onKeyDownReduce(e: Event) {
const ke = e as KeyboardEvent;
if (ke.key === "Enter" || ke.key === " ") {
ke.preventDefault();
human();
progress = progress < 0.5 ? 1 : 0;
RENDER(progress);
}
}
window.addEventListener("resize", fit);
fit();
if (reduce) {
root.classList.add("is-still");
progress = 0;
RENDER(progress);
panel.addEventListener("click", onClickReduce);
panel.addEventListener("keydown", onKeyDownReduce);
} else {
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
panel.removeEventListener("click", onClickReduce);
panel.removeEventListener("keydown", onKeyDownReduce);
} else {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.root} is-demo`} ref={rootRef}>
<p className={styles.hint}>
<span>클릭하거나 Enter 를 누르면 밝기 정렬 진행률 값이 줄며 픽셀이 원래 자리로 돌아갑니다</span>
</p>
<div
className={styles.panel}
ref={panelRef}
tabIndex={0}
role="button"
aria-label="밝기 정렬 진행률 값이 되돌리는 이미지 전환 다시 재생"
>
<canvas className={styles.cv} ref={cvRef} aria-hidden="true" />
</div>
</div>
</main>
);
}04 Image Transition Where a Brightness-sort Progress Value Reverses — Measured area 28.2% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Glitch Effect Transitions (No Codepen)
The post this demo lives in
9 CSS Glitch Effect Transitions (No Codepen)This is a css glitch effect: An offset value pulls RGB channels apart on load, then back together. The same trick spikes See all demos in this post
If you like this effect - similar demos
Transitions08 Transition Where Cards Slide as a Sort-Order Value ChangesView Transitions API: 9 Patterns by One Value
Transitions06 Glitch Transition Whose Row Offsets Are Computed From a Fixed SeedImage Reveal Effect CSS — 9 Canvas-computed Wipes
Transitions04 Circle reveal from click point9 CSS Page Transitions — No Library, Pure CSS