diff --git a/src/components/tech-stack-carousel/tech-stack-carousel.ts b/src/components/tech-stack-carousel/tech-stack-carousel.ts
index 3c5a62a..c7b5949 100644
--- a/src/components/tech-stack-carousel/tech-stack-carousel.ts
+++ b/src/components/tech-stack-carousel/tech-stack-carousel.ts
@@ -17,7 +17,12 @@ interface Skill {
})
export class TechStackCarousel implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef;
- private autoScrollTimer: ReturnType | null = null;
+ private autoScrollFrameId: number | null = null;
+ private startAutoScrollTimeout: ReturnType | 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;
}
- this.resumeAutoScroll();
+ // 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 {