constant auto-scrolling
All checks were successful
publish.yml / publish (push) Successful in 1m4s

This commit is contained in:
2026-04-16 20:01:54 +02:00
parent 6f30fa2260
commit 17fbfc5022
3 changed files with 71 additions and 22 deletions

View File

@@ -50,6 +50,10 @@ h2 {
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
}
.stack-carousel.is-auto-scrolling {
scroll-snap-type: none;
}
.stack-slide {
flex: 0 0 min(320px, calc(100% - 0.2rem));
scroll-snap-align: start;

View File

@@ -18,8 +18,14 @@
</button>
<div class="stack-carousel-column">
<div class="stack-carousel" #stackCarousel role="region" aria-label="Tech stack carousel">
@for (skill of skills; track skill.name) {
<div
class="stack-carousel"
[class.is-auto-scrolling]="isAutoScrolling"
#stackCarousel
role="region"
aria-label="Tech stack carousel"
>
@for (skill of renderedSkills; track $index) {
<app-card cardClass="skill-card stack-slide">
<div class="skill-heading">
<span class="skill-icon" role="img" [attr.aria-label]="skill.name + ' logo'">

View File

@@ -17,7 +17,12 @@ interface Skill {
})
export class TechStackCarousel implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
private autoScrollTimer: ReturnType<typeof setInterval> | null = null;
private autoScrollFrameId: number | null = null;
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
private lastAutoScrollTime = 0;
private virtualScrollLeft = 0;
private readonly autoScrollSpeedPxPerSecond = 72;
isAutoScrolling = false;
readonly skills: Skill[] = [
{ name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' },
@@ -34,6 +39,8 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
{ name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' },
];
readonly renderedSkills: Skill[] = [...this.skills, ...this.skills];
ngAfterViewInit(): void {
const prefersReducedMotion =
typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -42,11 +49,21 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
return;
}
// Defer start to the next task to avoid NG0100 in dev mode.
this.startAutoScrollTimeout = setTimeout(() => {
this.startAutoScrollTimeout = null;
this.resumeAutoScroll();
}, 0);
}
ngOnDestroy(): void {
if (this.startAutoScrollTimeout !== null) {
clearTimeout(this.startAutoScrollTimeout);
this.startAutoScrollTimeout = null;
}
this.pauseAutoScroll();
this.isAutoScrolling = false;
}
scrollStack(direction: -1 | 1): void {
@@ -59,22 +76,45 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
}
pauseAutoScroll(): void {
if (!this.autoScrollTimer) {
if (this.autoScrollFrameId === null) {
return;
}
clearInterval(this.autoScrollTimer);
this.autoScrollTimer = null;
cancelAnimationFrame(this.autoScrollFrameId);
this.autoScrollFrameId = null;
this.lastAutoScrollTime = 0;
}
resumeAutoScroll(): void {
if (this.autoScrollTimer) {
if (this.autoScrollFrameId !== null) {
return;
}
this.autoScrollTimer = setInterval(() => {
this.autoAdvanceStack();
}, 3200);
this.isAutoScrolling = true;
const step = (timestamp: number) => {
const carousel = this.stackCarousel?.nativeElement;
if (!carousel) {
this.autoScrollFrameId = null;
this.lastAutoScrollTime = 0;
this.virtualScrollLeft = 0;
this.isAutoScrolling = false;
return;
}
if (this.lastAutoScrollTime === 0) {
const loopWidth = this.getLoopWidth(carousel);
this.virtualScrollLeft = loopWidth > 0 ? carousel.scrollLeft % loopWidth : 0;
}
const deltaMs = this.lastAutoScrollTime > 0 ? Math.min(timestamp - this.lastAutoScrollTime, 40) : 16;
this.lastAutoScrollTime = timestamp;
this.autoAdvanceStack(carousel, deltaMs);
this.autoScrollFrameId = requestAnimationFrame(step);
};
this.autoScrollFrameId = requestAnimationFrame(step);
}
onCarouselFocusOut(event: FocusEvent): void {
@@ -92,23 +132,22 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
wrapper?.classList.add('is-fallback');
}
private autoAdvanceStack(): void {
const carousel = this.stackCarousel?.nativeElement;
if (!carousel) {
private autoAdvanceStack(carousel: HTMLElement, deltaMs: number): void {
const loopWidth = this.getLoopWidth(carousel);
if (loopWidth <= 0) {
return;
}
const maxScroll = carousel.scrollWidth - carousel.clientWidth;
if (maxScroll <= 0) {
return;
this.virtualScrollLeft += (this.autoScrollSpeedPxPerSecond * deltaMs) / 1000;
if (this.virtualScrollLeft >= loopWidth) {
this.virtualScrollLeft %= loopWidth;
}
if (carousel.scrollLeft >= maxScroll - 4) {
carousel.scrollTo({ left: 0, behavior: 'smooth' });
return;
carousel.scrollLeft = this.virtualScrollLeft;
}
carousel.scrollBy({ left: this.getScrollStep(carousel), behavior: 'smooth' });
private getLoopWidth(carousel: HTMLElement): number {
return carousel.scrollWidth / 2;
}
private getScrollStep(carousel: HTMLElement): number {