feat: better marquee and js-less theme switch

This commit is contained in:
Oleksandr 2026-02-13 07:46:00 +02:00
parent 2ca3604414
commit da6dd0100b
Signed by: Xanazf
GPG key ID: 821EEC32761AC17C
17 changed files with 689 additions and 831 deletions

View file

@ -3,6 +3,7 @@ import matrixLogo from "@icons/matrix-logo.svg?raw";
import discordLogo from "@icons/discord-logo.svg?raw";
import gitLogo from "@icons/git-logo.svg?raw";
import { ThemeSelect } from "./hooks/ThemeSwitch";
import ThemeToggle from "./ThemeToggle.astro";
interface Props {
class?: string;
@ -19,7 +20,7 @@ const props = Astro.props;
and our contributors
</a>
</div>
<ThemeSelect client:load />
<ThemeToggle />
<div class="socials-changelog">
<section class="socials">
<a href="https://matrix.to/#/#quickshell:outfoxxed.me" target="_blank" aria-label="Join our matrix space">

View file

@ -0,0 +1,59 @@
---
import { Icon } from "astro-icon/components";
---
<label class="theme-toggle icon-button standard" title="Toggle theme">
<input
type="checkbox"
id="theme-manual-toggle"
class="theme-toggle-input"
aria-label="Toggle theme (light/dark)"
/>
<Icon
name="sun"
class="light-icon"
style="width: 24px; height: 24px;"
aria-hidden="true"
/>
<Icon
name="moon"
class="dark-icon"
style="width: 24px; height: 24px;"
aria-hidden="true"
/>
<div class="state-layer"></div>
</label>
<style>
.theme-toggle {
cursor: pointer;
user-select: none;
}
.theme-toggle-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.light-icon {
display: block;
}
.dark-icon {
display: none;
}
.theme-toggle:has(.theme-toggle-input:checked) .light-icon {
display: none;
}
.theme-toggle:has(.theme-toggle-input:checked) .dark-icon {
display: block;
}
.theme-toggle:focus-within {
outline: 2px solid var(--accent-600);
border-radius: 50%;
}
</style>

View file

@ -1,4 +1,5 @@
---
// NOTE: to be migrated to @config/styling/animations_helper.ts
---
<script>
window.addEventListener('DOMContentLoaded', () => {

View file

@ -1,3 +1,4 @@
// NOTE: to be replaced by @components/ThemeToggle.astro
import {
createSignal,
createEffect,

View file

@ -14,4 +14,4 @@ import MarqueeContent from "./MarqueeContent.astro";
<MarqueeContent/>
</div>
<script src="@config/styling/marquee_old.ts"/>
<script src="@config/styling/marquee.ts"/>

View file

@ -0,0 +1,29 @@
export function initAnimations() {
const observerOptions = {
root: null,
rootMargin: "0px",
threshold: 0.1,
};
const observer = new IntersectionObserver(
(entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target);
}
});
},
observerOptions
);
const animatedElements = document.querySelectorAll(
".animate-fade-up, .stagger-parent"
);
animatedElements.forEach(el => observer.observe(el));
}
// auto-init on DOMContentLoaded
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", initAnimations);
}

View file

@ -1,200 +1,226 @@
// NOTE: at last index, append every item 1 by 1 starting from 0
document.addEventListener("DOMContentLoaded", () => {
const container = document.querySelector(
".marquee-item"
) as HTMLDivElement;
const scroller = document.querySelector(
".marquee-content"
) as HTMLDivElement;
if (!scroller) {
return;
}
const container = document.querySelector(".marquee") as HTMLDivElement;
const scroller = document.querySelector(".marquee-content") as HTMLDivElement;
const btnLeft = document.getElementById("marquee-scroll-left");
const btnRight = document.getElementById("marquee-scroll-right");
const sections = Array.from(
scroller.querySelectorAll(".marquee-item")
);
if (!container || !scroller) return;
const smoothFactor = 0.05;
const touchSensitivity = 2.5;
const bufferSize = 2;
let items = Array.from(
scroller.querySelectorAll(".marquee-item")
) as HTMLDivElement[];
const originalCount = items.length;
if (originalCount === 0) return;
let itemWidth = 0;
let sequenceWidth = 0;
let targetScrollX = 0;
let currentScrollX = 0;
let isAnimating = false;
let isDown = false;
let lastTouchX = 0;
let touchVelocity = 0;
let lastTouchTime = 0;
const smoothFactor = 0.1;
const snapThreshold = 0.1;
// setup clones
const setupClones = () => {
// remove existing clones
scroller.querySelectorAll(".clone").forEach((c) => c.remove());
const originals = Array.from(scroller.querySelectorAll(".marquee-item")) as HTMLDivElement[];
// add clones after
for (let i = 0; i < bufferSize; i++) {
originals.forEach((item) => {
const clone = item.cloneNode(true) as HTMLDivElement;
clone.classList.add("clone");
scroller.appendChild(clone);
});
}
// add clones before
const beforeContainer = document.createDocumentFragment();
for (let i = 0; i < bufferSize; i++) {
originals.forEach((item) => {
const clone = item.cloneNode(true) as HTMLDivElement;
clone.classList.add("clone");
beforeContainer.appendChild(clone);
});
}
scroller.insertBefore(beforeContainer, scroller.firstChild);
items = Array.from(scroller.querySelectorAll(".marquee-item")) as HTMLDivElement[];
};
const updateDimensions = () => {
itemWidth = container.clientWidth;
if (itemWidth === 0) return;
sequenceWidth = originalCount * itemWidth;
// standardize width
scroller.style.width = `${items.length * itemWidth}px`;
items.forEach((item) => {
item.style.width = `${itemWidth}px`;
item.style.flex = `0 0 ${itemWidth}px`;
item.style.maxWidth = `${itemWidth}px`;
});
targetScrollX = bufferSize * sequenceWidth + (targetScrollX % sequenceWidth);
currentScrollX = targetScrollX;
scroller.style.transform = `translateX(-${currentScrollX}px)`;
};
const lerp = (start: number, end: number, factor: number) =>
start + (end - start) * factor;
const setupScroll = () => {
scroller.querySelectorAll(".clone").forEach(clone => {
clone.remove();
});
const originalSections = Array.from(
scroller.querySelectorAll(".marquee-item:not(.clone)")
);
const templateSections =
originalSections.length > 0 ? originalSections : sections;
let sequenceWidth = 0;
templateSections.forEach(section => {
sequenceWidth += parseFloat(
window.getComputedStyle(section).width
);
});
// Create clones before original sections
for (let i = -bufferSize; i < 0; i++) {
templateSections.forEach((section, index) => {
const clone = section.cloneNode(true) as HTMLDivElement;
clone.classList.add("clone");
clone.setAttribute("data-clone-index", `${i}-${index}`);
scroller.appendChild(clone);
});
const animate = () => {
if (!isDown && Math.abs(touchVelocity) < 0.1) {
// snap to nearest item if not interacting and close to one
const nearestItemScroll = Math.round(targetScrollX / itemWidth) * itemWidth;
if (Math.abs(targetScrollX - nearestItemScroll) < itemWidth * 0.5) {
targetScrollX = lerp(targetScrollX, nearestItemScroll, 0.1);
}
}
// Add original sections if none exist
if (originalSections.length === 0) {
templateSections.forEach((section, index) => {
const clone = section.cloneNode(true) as HTMLDivElement;
clone.setAttribute("data-clone-index", `0-${index}`);
scroller.appendChild(clone);
});
}
currentScrollX = lerp(currentScrollX, targetScrollX, smoothFactor);
// Create clones after original sections
for (let i = 1; i <= bufferSize; i++) {
templateSections.forEach((section, index) => {
const clone = section.cloneNode(true) as HTMLDivElement;
clone.classList.add("clone");
clone.setAttribute("data-clone-index", `${i}-${index}`);
scroller.appendChild(clone);
});
}
scroller.style.width = `${sequenceWidth * (1 + bufferSize * 2)}px`;
targetScrollX = sequenceWidth * bufferSize;
currentScrollX = targetScrollX;
scroller.style.transform = `translateX(-${currentScrollX}px)`;
return sequenceWidth;
};
const checkBoundaryAndReset = (sequenceWidth: number) => {
if (currentScrollX > sequenceWidth * (bufferSize + 0.5)) {
targetScrollX -= sequenceWidth;
// boundary reset
if (currentScrollX > (bufferSize + 1) * sequenceWidth) {
currentScrollX -= sequenceWidth;
scroller.style.transform = `translateX(-${currentScrollX}px)`;
return true;
}
if (currentScrollX < sequenceWidth * (bufferSize - 0.5)) {
targetScrollX += sequenceWidth;
targetScrollX -= sequenceWidth;
} else if (currentScrollX < (bufferSize - 1) * sequenceWidth) {
currentScrollX += sequenceWidth;
scroller.style.transform = `translateX(-${currentScrollX}px)`;
return true;
targetScrollX += sequenceWidth;
}
return false;
};
const animate = (
sequenceWidth: number,
forceProgressReset = false
) => {
currentScrollX = lerp(
currentScrollX,
targetScrollX,
smoothFactor
);
scroller.style.transform = `translateX(-${currentScrollX}px)`;
if (Math.abs(targetScrollX - currentScrollX) > 0.01) {
requestAnimationFrame(() => animate(sequenceWidth));
// fade in-out and scale items based on distance from center
items.forEach((item, index) => {
const itemCenter = index * itemWidth;
const distance = Math.abs(currentScrollX - itemCenter);
const progress = Math.min(distance / itemWidth, 1); // 0 at center, 1 at edge
const opacity = 1 - progress;
const scale = 1 - progress * 0.1; // scale down as it leaves
const yOffset = progress * 20; // slide down as it leaves
item.style.opacity = opacity.toString();
// NOTE: apply transform to the video container specifically
// to keep layout stable
const content = item.querySelector(".marquee-item-content") as HTMLElement;
if (content) {
content.style.transform = `scale(${scale}) translateY(${yOffset}px)`;
}
});
const diff = Math.abs(targetScrollX - currentScrollX);
const interaction = isDown || Math.abs(touchVelocity) > 0.1;
if (diff > snapThreshold || interaction) {
requestAnimationFrame(animate);
} else {
isAnimating = false;
currentScrollX = targetScrollX;
scroller.style.transform = `translateX(-${currentScrollX}px)`;
}
};
// Initialize
const sequenceWidth = setupScroll();
const startAnimation = () => {
if (!isAnimating) {
isAnimating = true;
requestAnimationFrame(animate);
}
};
// Wheel event
container.addEventListener(
"wheel",
e => {
e.preventDefault();
targetScrollX += e.deltaY;
// video handling
const videos = scroller.querySelectorAll("video");
const observerOptions = {
root: container,
threshold: 0.5,
};
const needsReset = checkBoundaryAndReset(sequenceWidth);
if (!isAnimating) {
isAnimating = true;
requestAnimationFrame(() =>
animate(sequenceWidth, needsReset)
);
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const video = entry.target as HTMLVideoElement;
if (entry.isIntersecting) {
video.play().catch(() => {}); // Handle potential autoplay blocks
} else {
video.pause();
}
},
{ passive: false }
);
});
}, observerOptions);
// Touch events
container.addEventListener("touchstart", e => {
videos.forEach((v) => {
videoObserver.observe(v);
v.addEventListener("ended", () => {
targetScrollX += itemWidth;
startAnimation();
});
});
// events
btnLeft?.addEventListener("click", () => {
targetScrollX -= itemWidth;
startAnimation();
});
btnRight?.addEventListener("click", () => {
targetScrollX += itemWidth;
startAnimation();
});
container.addEventListener("wheel", (e) => {
e.preventDefault();
targetScrollX += e.deltaY;
startAnimation();
}, { passive: false });
container.addEventListener("touchstart", (e) => {
isDown = true;
lastTouchX = e.touches[0].clientX;
lastTouchTime = Date.now();
targetScrollX = currentScrollX;
touchVelocity = 0;
});
container.addEventListener("touchmove", e => {
container.addEventListener("touchmove", (e) => {
if (!isDown) return;
e.preventDefault();
const currentTouchX = e.touches[0].clientX;
const touchDelta = lastTouchX - currentTouchX;
const deltaX = lastTouchX - currentTouchX;
targetScrollX += deltaX * 1.5;
targetScrollX += touchDelta * touchSensitivity;
const now = Date.now();
const dt = now - lastTouchTime;
if (dt > 0) touchVelocity = deltaX / dt;
const currentTime = Date.now();
const timeDelta = currentTime - lastTouchTime;
if (timeDelta > 0) {
touchVelocity = (touchDelta / timeDelta) * 15;
}
lastTouchX = currentTouchX;
lastTouchTime = currentTime;
const needsReset = checkBoundaryAndReset(sequenceWidth);
if (!isAnimating) {
isAnimating = true;
requestAnimationFrame(() =>
animate(sequenceWidth, needsReset)
);
}
lastTouchTime = now;
startAnimation();
});
container.addEventListener("touchend", () => {
isDown = false;
targetScrollX += touchVelocity * 100; // Momentum
touchVelocity = 0;
startAnimation();
});
if (Math.abs(touchVelocity) > 0.1) {
targetScrollX += touchVelocity * 20;
window.addEventListener("resize", updateDimensions);
const decayVelocity = () => {
touchVelocity *= 0.95;
if (Math.abs(touchVelocity) > 0.1) {
targetScrollX += touchVelocity;
requestAnimationFrame(decayVelocity);
}
};
requestAnimationFrame(decayVelocity);
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
videos.forEach(v => v.pause());
}
});
// init
setupClones();
setTimeout(() => {
updateDimensions();
startAnimation();
}, 50);
});

View file

@ -1,144 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const marquee = document.getElementById("marquee-content")!;
marquee.style.setProperty("--scroll", "0");
window.addEventListener("load", autoplayInit, false);
let videos = document.getElementsByClassName(
"marquee-item-content"
) as HTMLCollectionOf<HTMLVideoElement>;
let vid_containers = document.getElementsByClassName(
"marquee-item"
) as HTMLCollectionOf<HTMLDivElement>;
let currentVideoIndex = 0;
let currentVideo: HTMLVideoElement | null = null;
function autoplayInit() {
setActiveVideo(0);
if (currentVideo) {
currentVideo.play();
currentVideo.style.animationPlayState = "running";
}
}
function setActiveVideo(index: number) {
if (currentVideo) {
currentVideo.pause();
}
currentVideoIndex = index;
currentVideo = videos[currentVideoIndex];
currentVideo.currentTime = 0;
marquee.style.setProperty("--scroll", `-${index * 100}%`);
marquee.style.setProperty("--mult", `${index + 1}`);
}
function offsetCarousel(offset: number) {
let nextIndex = currentVideoIndex + offset;
if (nextIndex === videos.length - 1) {
nextIndex = shiftItems(nextIndex);
marquee.style.setProperty(
"--scroll",
`-${(nextIndex - 1) * 100}%`
);
marquee.style.setProperty("--mult", `${nextIndex - 1}`);
}
// NOTE: previous behavior
// nextIndex = nextIndex % videos.length;
setActiveVideo(nextIndex);
}
function shiftItems(index: number) {
const vid_arr = Array.from(vid_containers);
const shifted = vid_arr.shift()! as HTMLDivElement;
shifted.setAttribute("clone", "");
marquee.firstElementChild?.remove();
marquee.appendChild(shifted);
videos = marquee.getElementsByClassName(
"marquee-item-content"
) as HTMLCollectionOf<HTMLVideoElement>;
vid_containers = document.getElementsByClassName(
"marquee-item"
) as HTMLCollectionOf<HTMLDivElement>;
return index - 1;
}
const intersectionOptions = {
root: marquee,
rootMargin: "0px",
threshold: 0.0,
};
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const video = entry.target as HTMLVideoElement;
if (!entry.isIntersecting) {
video.pause();
video.style.animationName = "none";
void video.offsetWidth;
video.style.animationName = "fade";
video.style.animationDuration = "0.3s";
video.style.animationTimingFunction = "ease-in-out";
video.style.animationFillMode = "forwards";
video.style.animationDirection = "reverse";
} else if (video === currentVideo) {
video.play();
video.style.animationName = "none";
void video.offsetWidth;
video.style.animationName = "fade";
video.style.animationDuration = "0.3s";
video.style.animationTimingFunction = "ease-in-out";
video.style.animationFillMode = "forwards";
video.style.animationPlayState = "running";
video.style.animationDirection = "normal";
}
});
}, intersectionOptions);
for (const video of videos) {
observer.observe(video);
video.addEventListener("ended", () => {
// The "ended" event might just mean its buffering.
if (
video === currentVideo &&
video.duration !== 0 &&
video.currentTime === video.duration
) {
offsetCarousel(1);
}
});
}
let wasPaused = false;
document.addEventListener("visibilitychange", () => {
if (currentVideo) {
if (document.hidden) {
wasPaused = currentVideo.paused;
currentVideo.pause();
} else if (!wasPaused) {
currentVideo.play();
}
}
});
// left-right buttons
document
.getElementById("marquee-scroll-left")!
.addEventListener("mousedown", () => offsetCarousel(-1));
document
.getElementById("marquee-scroll-right")!
.addEventListener("mousedown", () => offsetCarousel(1));
});

View file

@ -19,6 +19,9 @@ const { title, description } = Astro.props;
<body class="baselayout">
<!--<Header />-->
<slot />
<script>
import "@config/styling/animations_helper.ts";
</script>
</body>
</html>

View file

@ -30,29 +30,31 @@
display: flex;
width: 100%;
margin-block: var(--xl);
justify-content: space-between;
align-items: center;
scroll-snap-type: x mandatory;
justify-content: flex-start;
align-items: flex-start;
overflow: hidden;
}
.marquee-content {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
margin: 0;
padding: 0;
will-change: transform;
transform: translateX(0);
}
.marquee-item {
position: relative;
flex: 1 0 100%;
display: flex;
flex-direction: column;
align-items: center;
transition: left 0.3s var(--ease-in-out);
left: var(--scroll);
gap: var(--md);
padding-inline: 0.5rem;
box-sizing: border-box;
will-change: opacity;
&>* {
z-index: 11;
@ -72,32 +74,37 @@
.marquee-item-content {
border-radius: var(--radius-sm);
will-change: transform;
}
.marquee-scroll {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 85rem;
height: 100%;
display: flex;
justify-content: space-between;
align-items: stretch;
transition:
background-color 0.3s,
opacity 0.3s;
z-index: 20;
user-select: none;
align-items: stretch;
pointer-events: none;
padding-inline: 1rem;
}
.marquee-scroll-arrow {
max-width: 8rem;
width: 8rem;
font-size: 2rem;
pointer-events: all;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
&>div {
width: 2.5rem;
@ -152,9 +159,5 @@
border-radius: var(--radius-xs);
}
}
.marquee-scroll {
width: 92%;
left: 4%;
}
}

View file

@ -21,7 +21,7 @@ html {
position: relative;
margin: 0;
padding: 0;
transition: all 0.3s var(--ease-in-out);
/* transition: all 0.15s var(--ease-in-out); */
}
body {

View file

@ -56,7 +56,7 @@
--footer-bkg-border: var(--blue) 32% 84%;
}
html.dark {
html:has(input#theme-manual-toggle:checked) {
/* accent */
--green: 141deg;
--accent-400: var(--green) 100% 67%;