app
Circuit Trace Generators
Public Made by Adomby adom
Two dependency-free, seeded circuit-background generators: a seamless torus tile with ambient or progressive reveal, and the v9 continuous field with obstacles, scroll reveal, and five ambient systems.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULT_TRACE_WINDOW = 0.085;
let instanceCount = 0;
export const SCROLL_REVEAL_DEFAULTS = Object.freeze({
progressSource: "scroll",
spawnMode: "flow",
spawnAngle: 90,
spawnSwirl: 0.075,
spawnOriginX: 0.5,
spawnOriginY: 0.5,
traceWindow: DEFAULT_TRACE_WINDOW,
unlockLead: 0.025,
maxActiveTraces: 72,
traceDuration: 950,
revealLaunchRate: 24,
scrollSpeedResponse: 0.6,
revealGlow: 0.82,
once: true,
startOffset: 0,
endOffset: 0,
autoStart: true,
});
export const TRACE_SPAWN_PRESETS = Object.freeze({
downstream: Object.freeze({
spawnMode: "flow",
spawnAngle: 90,
spawnSwirl: 0.075,
traceWindow: 0.085,
}),
diagonal: Object.freeze({
spawnMode: "flow",
spawnAngle: 32,
spawnSwirl: 0.105,
traceWindow: 0.1,
}),
radial: Object.freeze({
spawnMode: "radial",
spawnOriginX: 0.5,
spawnOriginY: 0.46,
traceWindow: 0.115,
}),
scatter: Object.freeze({
spawnMode: "scatter",
traceWindow: 0.075,
}),
});
function clamp(value, minimum = 0, maximum = 1) {
return Math.max(minimum, Math.min(maximum, value));
}
function smoothstep(value) {
const amount = clamp(value);
return amount * amount * (3 - 2 * amount);
}
function brandColor(x, width) {
const position = clamp(x / Math.max(1, width));
if (position < 0.5) return "hsl(" + (176 + position * 58) + " 92% 63%)";
return "hsl(" + (205 + (position - 0.5) * 86) + " 91% 70%)";
}
function hashSeed(value) {
let hash = 2166136261;
for (const character of String(value)) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(seed, index, salt = 0) {
let value = seed ^ Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt + 1, 0x85ebca6b);
value = Math.imul(value ^ (value >>> 16), 0x7feb352d);
value = Math.imul(value ^ (value >>> 15), 0x846ca68b);
return ((value ^ (value >>> 16)) >>> 0) / 4294967296;
}
function pathData(points) {
return points.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" ");
}
function polylineLength(points) {
let length = 0;
for (let index = 1; index < points.length; index += 1) {
length += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
);
}
return Math.max(1, length);
}
function svgElement(name, attributes = {}) {
const element = document.createElementNS(SVG_NS, name);
for (const [key, value] of Object.entries(attributes)) {
element.setAttribute(key, String(value));
}
return element;
}
function upperBound(values, target) {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (values[middle] <= target) low = middle + 1;
else high = middle;
}
return low;
}
function mediaListener(media, method, listener) {
if (media[method]) {
media[method]("change", listener);
return;
}
const legacyMethod = method === "addEventListener" ? "addListener" : "removeListener";
media[legacyMethod]?.(listener);
}
/**
* Queues trace growth as scroll progressively unlocks an existing circuit field.
*
* The module owns only its reveal layer. It temporarily hides the source trace
* layer and restores it on destroy, which keeps it independent from generation
* and ambient animation systems.
*/
export function createScrollTraceReveal(svg, field, config = {}, options = {}) {
if (!(svg instanceof SVGElement)) throw new TypeError("svg must be an SVG element");
if (!field || !Array.isArray(field.traces)) {
throw new TypeError("field must contain a traces array");
}
const settings = {
...SCROLL_REVEAL_DEFAULTS,
...options,
};
const sourceLayer = settings.traceLayer || svg.querySelector("[data-static-traces]");
if (!sourceLayer) throw new Error("A static circuit trace layer is required");
const rangeElement = settings.rangeElement || svg.parentElement || svg;
const scrollRoot = settings.scrollRoot || window;
const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
const seed = hashSeed(`${config.seed ?? "adom"}:scroll-reveal:${settings.seed ?? ""}`);
const featureScale = Math.max(0.25, Number(config.featureScale) || 1);
const traceWidth = Math.max(0.1, Number(config.traceWidth) || 1);
const viaOuterRadius = Math.max(traceWidth, Number(config.viaOuterRadius) || traceWidth * 2.2);
const viaInnerRadius = clamp(
Number(config.viaInnerRadius) || viaOuterRadius * 0.48,
0,
viaOuterRadius * 0.92,
);
const traceColor = config.traceColor || "currentColor";
const holeColor = settings.holeColor || "var(--circuit-hole, #0d1117)";
const configuredUnlockLead = Number(settings.unlockLead);
const unlockLead = clamp(
Number.isFinite(configuredUnlockLead) ? configuredUnlockLead : 0.025,
0,
0.25,
);
const instanceId = `circuit-scroll-reveal-${++instanceCount}`;
const viaSymbolId = `${instanceId}-via`;
const layer = svgElement("g", {
"data-animation": "scroll-trace-reveal",
"data-spawn-mode": settings.spawnMode,
"aria-hidden": "true",
});
const definitions = svgElement("defs");
const viaSymbol = svgElement("g", { id: viaSymbolId });
viaSymbol.append(
svgElement("circle", {
cx: 0,
cy: 0,
r: viaOuterRadius * featureScale,
fill: "currentColor",
}),
svgElement("circle", {
cx: 0,
cy: 0,
r: viaInnerRadius * featureScale,
fill: holeColor,
}),
);
definitions.append(viaSymbol);
layer.append(definitions);
const sourceDisplay = sourceLayer.style.getPropertyValue("display");
const sourceDisplayPriority = sourceLayer.style.getPropertyPriority("display");
sourceLayer.style.setProperty("display", "none");
sourceLayer.after(layer);
const fieldWidth = Math.max(1, Number(field.width) || 1);
const fieldHeight = Math.max(1, Number(field.height) || 1);
const span = Math.max(fieldWidth, fieldHeight);
const angle = (Number(settings.spawnAngle) || 0) * Math.PI / 180;
const direction = [Math.cos(angle), Math.sin(angle)];
const across = [-direction[1], direction[0]];
const phase = hashUnit(seed, 0, 11) * Math.PI * 2;
const origin = [
clamp(Number(settings.spawnOriginX), 0, 1) * fieldWidth,
clamp(Number(settings.spawnOriginY), 0, 1) * fieldHeight,
];
function potential(point, index) {
if (settings.spawnMode === "scatter") return hashUnit(seed, index, 19);
if (settings.spawnMode === "radial") {
const x = (point[0] - origin[0]) / fieldWidth;
const y = (point[1] - origin[1]) / fieldHeight;
return Math.hypot(x, y) + (hashUnit(seed, index, 23) - 0.5) * 0.025;
}
if (settings.spawnMode === "edges") {
const edgeDistance = Math.min(
point[0] / fieldWidth,
(fieldWidth - point[0]) / fieldWidth,
point[1] / fieldHeight,
(fieldHeight - point[1]) / fieldHeight,
);
return edgeDistance + (hashUnit(seed, index, 29) - 0.5) * 0.018;
}
if (settings.spawnMode === "index") return index;
const projection = point[0] * direction[0] + point[1] * direction[1];
const cross = point[0] * across[0] + point[1] * across[1];
const swirl = clamp(Number(settings.spawnSwirl) || 0, 0, 0.35);
return projection
+ Math.sin(cross / span * Math.PI * 3.2 + phase) * span * swirl
+ Math.sin((projection + cross * 0.42) / span * Math.PI * 1.7 - phase) * span * swirl * 0.34
+ (hashUnit(seed, index, 31) - 0.5) * span * 0.012;
}
const entries = field.traces.map((trace, index) => {
const originalPoints = trace.points || [];
if (originalPoints.length < 2) return null;
const first = originalPoints[0];
const last = originalPoints.at(-1);
const middle = originalPoints[Math.floor(originalPoints.length / 2)];
const orientByPotential = !["scatter", "index"].includes(settings.spawnMode);
const reverse = orientByPotential
&& potential(first, index) > potential(last, index);
const points = reverse ? [...originalPoints].reverse() : originalPoints;
const length = Math.max(1, Number(trace.length) || polylineLength(points));
return {
index,
trace,
points,
length,
key: potential(middle, index),
color: brandColor(middle[0], fieldWidth),
};
}).filter(Boolean);
entries.sort((first, second) => first.key - second.key || first.index - second.index);
const maximumActiveTraces = Math.round(clamp(Number(settings.maxActiveTraces) || 72, 12, 160));
const startDenominator = Math.max(1, entries.length - 1);
const starts = [];
const visuals = entries.map((entry, rank) => {
const start = rank / startDenominator;
starts.push(start);
const path = svgElement("path", {
d: pathData(entry.points),
fill: "none",
stroke: traceColor,
"stroke-width": traceWidth * featureScale,
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
"stroke-dasharray": `${entry.length} ${entry.length + 1}`,
"stroke-dashoffset": entry.length,
opacity: 0,
"data-scroll-trace": entry.index,
});
const startVia = svgElement("use", {
href: `#${viaSymbolId}`,
transform: `translate(${entry.points[0][0]} ${entry.points[0][1]})`,
opacity: 0,
"data-scroll-via": "start",
color: traceColor,
});
const endVia = svgElement("use", {
href: `#${viaSymbolId}`,
transform: `translate(${entry.points.at(-1)[0]} ${entry.points.at(-1)[1]})`,
opacity: 0,
"data-scroll-via": "end",
color: traceColor,
});
layer.append(path, startVia, endVia);
return {
...entry,
start,
path,
startVia,
endVia,
state: "hidden",
localProgress: -1,
startedAt: 0,
duration: 0,
};
});
let running = false;
let destroyed = false;
let frameRequest = 0;
let scrollUpdatePending = false;
let progress = 0;
let maximumProgress = 0;
let visualProgress = 0;
let reportedProgress = -1;
let unlockedEnd = 0;
let launchedEnd = 0;
let completedCount = 0;
let settledEnd = 0;
const pendingSettleRanges = [];
let pendingSettleCount = 0;
let launchCredit = 1;
let previousFrameTime = 0;
let lastVisualUpdateTime = 0;
let stoppedAt = 0;
let hiddenAt = 0;
let lastScrollPosition;
let lastScrollTime = 0;
let scrollVelocity = 0;
const active = new Set();
let frameCount = 0;
let resizeObserver;
const configuredDurationValue = Number(config.revealDuration);
const configuredDuration = Number.isFinite(configuredDurationValue) && configuredDurationValue > 0
? configuredDurationValue
: 7600;
const configuredPower = Number(config.revealPower);
const revealPower = clamp(Number.isFinite(configuredPower) ? configuredPower : 0.72);
const revealMotion = config.revealMotion !== false;
const densityDurationFactor = clamp(
Math.sqrt(600 / Math.max(1, visuals.length)),
0.55,
1.15,
);
const requestedTraceDuration = Number(settings.traceDuration);
const baseTraceDuration = Number.isFinite(requestedTraceDuration)
? clamp(requestedTraceDuration, 240, 4000)
: clamp(
configuredDuration / 8 * (1.08 - revealPower * 0.18) * densityDurationFactor,
440,
1450,
);
const baseLaunchRate = clamp(Number(settings.revealLaunchRate) || 24, 1, 180);
const speedResponse = clamp(Number(settings.scrollSpeedResponse) || 0, 0, 1.5);
const glowStrength = clamp(Number(settings.revealGlow) || 0, 0, 1.5);
const activeFrameInterval = visuals.length > 1200
? 1000 / 30
: visuals.length > 600
? 1000 / 45
: 0;
function setHidden(visual) {
if (visual.state === "hidden") return;
visual.path.setAttribute("stroke-dashoffset", String(visual.length));
visual.path.setAttribute("opacity", "0");
visual.path.style.removeProperty("filter");
visual.startVia.setAttribute("opacity", "0");
visual.endVia.setAttribute("opacity", "0");
visual.state = "hidden";
visual.localProgress = 0;
visual.startedAt = 0;
visual.duration = 0;
}
function setComplete(visual) {
if (visual.state === "complete") return;
visual.path.setAttribute("stroke-dashoffset", "0");
visual.path.setAttribute("stroke", traceColor);
visual.startVia.setAttribute("color", traceColor);
visual.endVia.setAttribute("color", traceColor);
visual.path.setAttribute("opacity", "1");
visual.path.style.removeProperty("filter");
visual.startVia.setAttribute("opacity", "1");
visual.endVia.setAttribute("opacity", "1");
visual.state = "complete";
visual.localProgress = 1;
}
function setPartial(visual, amount) {
const local = smoothstep(amount);
if (visual.state === "partial" && Math.abs(local - visual.localProgress) < 0.001) return;
visual.path.setAttribute("stroke-dashoffset", String(visual.length * (1 - local)));
visual.path.setAttribute("stroke", visual.color);
visual.startVia.setAttribute("color", visual.color);
visual.endVia.setAttribute("color", visual.color);
visual.path.setAttribute("opacity", String(clamp(0.42 + local * 0.58)));
visual.path.style.filter = glowStrength > 0.01
? "drop-shadow(0 0 " + (1.5 + glowStrength * 4 * featureScale) + "px " + visual.color + ")"
: "none";
visual.startVia.setAttribute("opacity", String(smoothstep(local / 0.12)));
visual.endVia.setAttribute("opacity", String(smoothstep((local - 0.82) / 0.18)));
visual.state = "partial";
visual.localProgress = local;
}
function advanceSettledEnd() {
while (settledEnd < visuals.length && visuals[settledEnd].state === "complete") {
settledEnd += 1;
}
}
function reportVisualProgress(force = false) {
advanceSettledEnd();
const frontier = visuals[settledEnd];
const frontierProgress = frontier?.state === "partial"
? clamp(frontier.localProgress)
: 0;
const nextVisual = visuals.length > 0
? (settledEnd + frontierProgress) / visuals.length
: 1;
visualProgress = force && nextVisual < visualProgress
? nextVisual
: Math.max(visualProgress, nextVisual);
if (force || Math.abs(visualProgress - reportedProgress) >= 0.0001) {
reportedProgress = visualProgress;
settings.onProgress?.(visualProgress, api);
}
}
function unlock(nextProgress) {
const nextUnlockedEnd = upperBound(starts, clamp(nextProgress + unlockLead));
unlockedEnd = Math.max(unlockedEnd, nextUnlockedEnd);
}
function launch(visual, now) {
const speedFactor = 1 - clamp(scrollVelocity / 2.4) * speedResponse * 0.42;
const lengthFactor = 0.8 + clamp(visual.length / span, 0, 0.55);
const jitter = 0.88 + hashUnit(seed, visual.index, 41) * 0.24;
visual.startedAt = now;
visual.duration = clamp(
baseTraceDuration * lengthFactor * jitter * speedFactor,
360,
1750,
);
visual.localProgress = -1;
setPartial(visual, 0);
active.add(visual);
launchedEnd += 1;
}
function updateActive(now) {
for (const visual of active) {
const amount = clamp((now - visual.startedAt) / visual.duration);
if (amount >= 1) {
setComplete(visual);
active.delete(visual);
completedCount += 1;
} else {
setPartial(visual, amount);
}
}
}
function queueExcessBacklog() {
const retainedBacklog = maximumActiveTraces * 2;
const excess = Math.max(0, unlockedEnd - launchedEnd - retainedBacklog);
if (!excess) return;
const start = launchedEnd;
const end = start + excess;
const previousRange = pendingSettleRanges.at(-1);
if (previousRange?.end === start) previousRange.end = end;
else pendingSettleRanges.push({ next: start, end });
pendingSettleCount += excess;
launchedEnd = end;
}
function materializePendingSettles(limit = 120) {
let remaining = limit;
while (remaining > 0 && pendingSettleRanges.length > 0) {
const range = pendingSettleRanges[0];
const visual = visuals[range.next];
setComplete(visual);
completedCount += 1;
pendingSettleCount -= 1;
range.next += 1;
remaining -= 1;
if (range.next >= range.end) pendingSettleRanges.shift();
}
}
function launchQueued(now, elapsed) {
queueExcessBacklog();
materializePendingSettles(120);
const speed = clamp(scrollVelocity / 2.4);
const backlog = clamp(
(unlockedEnd - launchedEnd) / Math.max(1, maximumActiveTraces * 2),
);
const launchRate = Math.min(240,
baseLaunchRate * (1 + speed * speedResponse * 2.1 + backlog * 0.9),
);
launchCredit = Math.min(12, launchCredit + elapsed * launchRate / 1000);
while (
launchCredit >= 1
&& launchedEnd < unlockedEnd
&& active.size < maximumActiveTraces
) {
launch(visuals[launchedEnd], now);
launchCredit -= 1;
}
if (launchedEnd >= unlockedEnd) launchCredit = Math.min(1, launchCredit);
}
function scrollMetrics() {
const rootIsWindow = scrollRoot === window;
const rootRect = rootIsWindow
? { top: 0, height: window.innerHeight }
: scrollRoot.getBoundingClientRect();
const elementRect = rangeElement.getBoundingClientRect();
const scrollPosition = rootIsWindow ? window.scrollY : scrollRoot.scrollTop;
const viewportHeight = rootIsWindow ? window.innerHeight : scrollRoot.clientHeight;
const elementTop = elementRect.top - rootRect.top + scrollPosition;
const elementHeight = elementRect.height;
const start = Number.isFinite(settings.scrollStart)
? settings.scrollStart
: elementTop + Number(settings.startOffset || 0);
let end = Number.isFinite(settings.scrollEnd)
? settings.scrollEnd
: elementTop + elementHeight - viewportHeight + Number(settings.endOffset || 0);
if (end <= start) end = start + Math.max(elementHeight, viewportHeight, 1);
return {
start,
end,
scrollPosition,
progress: clamp((scrollPosition - start) / (end - start)),
};
}
function complete() {
if (destroyed) return api;
if (
settledEnd === visuals.length
&& completedCount === visuals.length
&& visualProgress === 1
) return api;
active.clear();
pendingSettleRanges.length = 0;
pendingSettleCount = 0;
unlockedEnd = visuals.length;
launchedEnd = visuals.length;
completedCount = visuals.length;
settledEnd = visuals.length;
for (const visual of visuals) setComplete(visual);
maximumProgress = 1;
progress = 1;
visualProgress = 1;
reportVisualProgress(true);
return api;
}
function updateScrollTarget(now = performance.now()) {
if (destroyed || settings.progressSource === "manual") return;
const metrics = scrollMetrics();
if (lastScrollPosition !== undefined && lastScrollTime) {
const elapsed = Math.max(1, now - lastScrollTime);
const forwardDelta = Math.max(0, metrics.scrollPosition - lastScrollPosition);
const instantVelocity = forwardDelta / elapsed;
scrollVelocity = scrollVelocity * 0.58 + instantVelocity * 0.42;
}
lastScrollPosition = metrics.scrollPosition;
lastScrollTime = now;
const next = metrics.progress;
progress = settings.once ? Math.max(maximumProgress, next) : next;
maximumProgress = Math.max(maximumProgress, next);
if (!revealMotion || motionPreference.matches) {
complete();
return;
}
unlock(maximumProgress);
}
function renderFrame(now) {
frameRequest = 0;
if (destroyed || !running) return;
if (scrollUpdatePending && settings.progressSource !== "manual") {
scrollUpdatePending = false;
updateScrollTarget(now);
}
const frameElapsed = previousFrameTime ? clamp(now - previousFrameTime, 0, 64) : 16;
previousFrameTime = now;
scrollVelocity *= Math.pow(0.91, frameElapsed / 16.67);
const visualFrameDue = !activeFrameInterval
|| !lastVisualUpdateTime
|| now - lastVisualUpdateTime >= activeFrameInterval;
if (visualFrameDue) {
const visualElapsed = lastVisualUpdateTime
? clamp(now - lastVisualUpdateTime, 0, 100)
: 16;
lastVisualUpdateTime = now;
updateActive(now);
launchQueued(now, visualElapsed);
reportVisualProgress();
frameCount += 1;
}
if (
scrollUpdatePending
|| active.size > 0
|| launchedEnd < unlockedEnd
|| pendingSettleCount > 0
) {
scheduleFrame();
} else {
previousFrameTime = 0;
lastVisualUpdateTime = 0;
}
}
function scheduleFrame() {
if (frameRequest || destroyed || !running) return;
frameRequest = window.requestAnimationFrame(renderFrame);
}
function requestUpdate() {
if (destroyed || !running) return;
scrollUpdatePending = true;
scheduleFrame();
}
function setProgress(nextProgress, { remember = settings.once } = {}) {
if (destroyed) return api;
const next = clamp(Number(nextProgress) || 0);
progress = remember ? Math.max(maximumProgress, next) : next;
maximumProgress = Math.max(maximumProgress, progress);
if (!revealMotion || motionPreference.matches) {
complete();
} else {
unlock(maximumProgress);
scheduleFrame();
}
return api;
}
function refresh() {
if (destroyed) return api;
if (settings.progressSource === "manual") {
if (!revealMotion || motionPreference.matches) complete();
else {
unlock(maximumProgress);
scheduleFrame();
}
} else {
updateScrollTarget();
scheduleFrame();
}
return api;
}
function reset() {
if (destroyed) return api;
active.clear();
pendingSettleRanges.length = 0;
pendingSettleCount = 0;
for (const visual of visuals) setHidden(visual);
maximumProgress = 0;
progress = 0;
visualProgress = 0;
reportedProgress = -1;
unlockedEnd = 0;
launchedEnd = 0;
completedCount = 0;
settledEnd = 0;
launchCredit = 1;
previousFrameTime = 0;
lastVisualUpdateTime = 0;
scrollVelocity = 0;
lastScrollPosition = undefined;
lastScrollTime = 0;
if (!revealMotion || motionPreference.matches) return complete();
reportVisualProgress(true);
return api;
}
function onMotionPreferenceChange() {
if (!revealMotion || motionPreference.matches) complete();
else refresh();
}
function onVisibilityChange() {
const now = performance.now();
if (document.hidden) {
window.cancelAnimationFrame(frameRequest);
frameRequest = 0;
hiddenAt = now;
return;
}
if (!hiddenAt) return;
const pausedFor = now - hiddenAt;
for (const visual of active) visual.startedAt += pausedFor;
hiddenAt = 0;
previousFrameTime = 0;
lastVisualUpdateTime = 0;
scheduleFrame();
}
function start() {
if (destroyed || running) return api;
if (stoppedAt) {
const pausedFor = performance.now() - stoppedAt;
for (const visual of active) visual.startedAt += pausedFor;
stoppedAt = 0;
}
running = true;
hiddenAt = document.hidden ? performance.now() : 0;
document.addEventListener("visibilitychange", onVisibilityChange);
if (settings.progressSource !== "manual") {
scrollRoot.addEventListener("scroll", requestUpdate, { passive: true });
window.addEventListener("resize", requestUpdate, { passive: true });
resizeObserver = new ResizeObserver(requestUpdate);
resizeObserver.observe(rangeElement);
}
mediaListener(motionPreference, "addEventListener", onMotionPreferenceChange);
refresh();
return api;
}
function stop() {
if (!running) return api;
running = false;
const stoppedNow = performance.now();
if (hiddenAt) {
const hiddenFor = stoppedNow - hiddenAt;
for (const visual of active) visual.startedAt += hiddenFor;
hiddenAt = 0;
}
stoppedAt = stoppedNow;
window.cancelAnimationFrame(frameRequest);
frameRequest = 0;
scrollRoot.removeEventListener("scroll", requestUpdate);
window.removeEventListener("resize", requestUpdate);
resizeObserver?.disconnect();
resizeObserver = undefined;
mediaListener(motionPreference, "removeEventListener", onMotionPreferenceChange);
document.removeEventListener("visibilitychange", onVisibilityChange);
scrollUpdatePending = false;
previousFrameTime = 0;
lastVisualUpdateTime = 0;
return api;
}
function destroy() {
if (destroyed) return;
stop();
destroyed = true;
active.clear();
pendingSettleRanges.length = 0;
pendingSettleCount = 0;
layer.remove();
if (sourceDisplay) {
sourceLayer.style.setProperty("display", sourceDisplay, sourceDisplayPriority);
} else {
sourceLayer.style.removeProperty("display");
}
}
const api = {
start,
stop,
refresh,
reset,
complete,
setProgress,
destroy,
get progress() {
return progress;
},
get visualProgress() {
return visualProgress;
},
get layer() {
return layer;
},
get stats() {
return {
system: "scroll-trace-reveal",
spawnMode: settings.spawnMode,
progress,
targetProgress: maximumProgress,
visualProgress,
traceCount: visuals.length,
maximumActiveTraces,
unlockLead,
unlockedTraces: unlockedEnd,
queuedTraces: Math.max(0, unlockedEnd - launchedEnd),
catchUpTraces: pendingSettleCount,
hiddenTraces: Math.max(0, visuals.length - completedCount - active.size),
activeTraces: active.size,
completedTraces: completedCount,
settledTraces: settledEnd,
scrollVelocity,
animationNodes: layer.childElementCount,
frameCount,
running,
revealMotion,
reducedMotion: motionPreference.matches,
};
},
};
if (!revealMotion || motionPreference.matches) complete();
else reportVisualProgress(true);
if (settings.autoStart) start();
return api;
}