diff --git a/src/components/interactive-background/interactive-background.css b/src/components/interactive-background/interactive-background.css
index 07508b6..f1e0daf 100644
--- a/src/components/interactive-background/interactive-background.css
+++ b/src/components/interactive-background/interactive-background.css
@@ -16,34 +16,3 @@
height: 100%;
display: block;
}
-
-.custom-cursor {
- position: fixed;
- top: 0;
- left: 0;
- width: 26px;
- height: 26px;
- border: 1px solid rgba(147, 197, 253, 0.9);
- border-radius: 50%;
- transform: translate3d(-9999px, -9999px, 0);
- margin-left: -13px;
- margin-top: -13px;
- box-shadow: 0 0 24px rgba(56, 189, 248, 0.28);
- background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, rgba(96, 165, 250, 0) 70%);
- transition: width 130ms ease, height 130ms ease, margin 130ms ease, border-color 130ms ease;
-}
-
-.custom-cursor.pointer-down {
- width: 38px;
- height: 38px;
- margin-left: -19px;
- margin-top: -19px;
- border-color: rgba(196, 181, 253, 0.95);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .custom-cursor {
- transition: none;
- }
-}
-
diff --git a/src/components/interactive-background/interactive-background.html b/src/components/interactive-background/interactive-background.html
index 17cb4f8..f8ec183 100644
--- a/src/components/interactive-background/interactive-background.html
+++ b/src/components/interactive-background/interactive-background.html
@@ -1,11 +1,3 @@
- @if (isCursorVisible) {
-
- }
-
diff --git a/src/components/interactive-background/interactive-background.ts b/src/components/interactive-background/interactive-background.ts
index 2831d55..d02c93e 100644
--- a/src/components/interactive-background/interactive-background.ts
+++ b/src/components/interactive-background/interactive-background.ts
@@ -1,5 +1,4 @@
-import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core';
-import { NavigationStart, Router } from '@angular/router';
+import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
type PointerState = {
x: number;
@@ -277,11 +276,8 @@ class ParticleEngine {
})
export class InteractiveBackground implements AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef;
- private readonly router = inject(Router);
- isCursorVisible = InteractiveBackground.detectFinePointer();
isPointerDown = false;
- cursorTransform = 'translate3d(-9999px, -9999px, 0)';
private particleEngine: ParticleEngine | null = null;
private destroyCallbacks: Array<() => void> = [];
@@ -293,6 +289,7 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
private activeTouchId: number | null = null;
private reducedMotion = false;
private mediaQuery!: MediaQueryList;
+ private readonly pointerExcludeSelector = '[data-disable-bg-pointer]';
ngAfterViewInit(): void {
const canvas = this.canvasRef.nativeElement;
@@ -332,11 +329,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
const onTouchCancel = () => this.handlePointerLeave();
const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches);
- const routerSubscription = this.router.events.subscribe((event) => {
- if (event instanceof NavigationStart) {
- this.resetPointerInteraction();
- }
- });
window.addEventListener('resize', onResize);
@@ -375,7 +367,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
() => window.removeEventListener('touchend', onTouchEnd),
() => window.removeEventListener('touchcancel', onTouchCancel),
() => this.mediaQuery.removeEventListener('change', onMotionChange),
- () => routerSubscription.unsubscribe(),
];
}
@@ -435,6 +426,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
private handlePointerMove(event: PointerEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
return;
}
@@ -443,6 +439,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
private handlePointerDown(event: PointerEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
if (event.pointerType === 'touch') {
this.activeTouchId = event.pointerId;
}
@@ -452,6 +453,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
private handlePointerUp(event: PointerEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
this.isPointerDown = false;
if (event.pointerType === 'touch') {
@@ -460,27 +466,42 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
this.activeTouchId = null;
- this.resetPointerInteraction();
+ this.pointerActive = false;
return;
}
- this.resetPointerInteraction();
+ this.updatePointer(event.clientX, event.clientY, true);
}
private handleMouseMove(event: MouseEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
this.updatePointer(event.clientX, event.clientY, true);
}
private handleMouseDown(event: MouseEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
this.isPointerDown = true;
this.updatePointer(event.clientX, event.clientY, true);
}
private handleMouseUp(): void {
- this.resetPointerInteraction();
+ this.isPointerDown = false;
}
private handleTouchStart(event: TouchEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
const touch = event.changedTouches.item(0);
if (!touch) {
return;
@@ -492,6 +513,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
private handleTouchMove(event: TouchEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
if (this.activeTouchId === null) {
return;
}
@@ -505,6 +531,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
private handleTouchEnd(event: TouchEvent): void {
+ if (this.isPointerExcluded(event.target)) {
+ this.handlePointerLeave();
+ return;
+ }
+
if (this.activeTouchId === null) {
return;
}
@@ -515,25 +546,20 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
}
this.isPointerDown = false;
- this.resetPointerInteraction();
+ this.activeTouchId = null;
+ this.pointerActive = false;
}
private handlePointerLeave(): void {
- this.resetPointerInteraction();
- }
-
- private resetPointerInteraction(): void {
this.pointerActive = false;
this.isPointerDown = false;
this.activeTouchId = null;
- this.cursorTransform = 'translate3d(-9999px, -9999px, 0)';
}
private updatePointer(x: number, y: number, active: boolean): void {
this.pointerX = x;
this.pointerY = y;
this.pointerActive = active;
- this.cursorTransform = `translate3d(${x}px, ${y}px, 0)`;
}
private findTouch(touches: TouchList, identifier: number): Touch | null {
@@ -547,6 +573,14 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
return null;
}
+ private isPointerExcluded(target: EventTarget | null): boolean {
+ if (!(target instanceof Element)) {
+ return false;
+ }
+
+ return target.closest(this.pointerExcludeSelector) !== null;
+ }
+
private syncPointer(): void {
this.particleEngine?.setPointer({
x: this.pointerX,
@@ -591,13 +625,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
};
}
- private static detectFinePointer(): boolean {
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
- return false;
- }
-
- return window.matchMedia('(pointer: fine)').matches;
- }
}
diff --git a/src/pages/home/home.css b/src/pages/home/home.css
index c14b749..4fd31d6 100644
--- a/src/pages/home/home.css
+++ b/src/pages/home/home.css
@@ -237,7 +237,19 @@ h2 {
background: rgba(33, 38, 45, 0.8);
color: #e6edf3;
cursor: pointer;
- transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transform: translateY(2px);
+ transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease, opacity 120ms ease;
+}
+
+.stack-carousel-shell:hover .carousel-btn,
+.stack-carousel-shell:focus-within .carousel-btn {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: translateY(0);
}
.carousel-btn:hover {
diff --git a/src/pages/home/home.html b/src/pages/home/home.html
index eac0311..2ecbc06 100644
--- a/src/pages/home/home.html
+++ b/src/pages/home/home.html
@@ -34,7 +34,16 @@
Tech Stack
Primary technologies I use to deliver production-ready software.
-
+
diff --git a/src/pages/home/home.ts b/src/pages/home/home.ts
index ee1bdb1..85dd1c4 100644
--- a/src/pages/home/home.ts
+++ b/src/pages/home/home.ts
@@ -1,4 +1,4 @@
-import { Component, ElementRef, ViewChild } from '@angular/core';
+import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CardComponent } from '../../components/card/card';
import { PROJECTS, Project } from '../projects/project-data';
@@ -17,8 +17,9 @@ interface Skill {
templateUrl: './home.html',
styleUrl: './home.css',
})
-export class Home {
+export class Home implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef;
+ private autoScrollTimer: ReturnType | null = null;
readonly hero = {
name: 'Tim Kainz',
@@ -44,14 +45,56 @@ export class Home {
readonly projects: Project[] = PROJECTS;
+ ngAfterViewInit(): void {
+ const prefersReducedMotion =
+ typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ if (prefersReducedMotion) {
+ return;
+ }
+
+ this.resumeAutoScroll();
+ }
+
+ ngOnDestroy(): void {
+ this.pauseAutoScroll();
+ }
+
scrollStack(direction: -1 | 1): void {
const carousel = this.stackCarousel?.nativeElement;
if (!carousel) {
return;
}
- const cardWidth = Math.max(carousel.clientWidth * 0.82, 260);
- carousel.scrollBy({ left: cardWidth * direction, behavior: 'smooth' });
+ carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
+ }
+
+ pauseAutoScroll(): void {
+ if (!this.autoScrollTimer) {
+ return;
+ }
+
+ clearInterval(this.autoScrollTimer);
+ this.autoScrollTimer = null;
+ }
+
+ resumeAutoScroll(): void {
+ if (this.autoScrollTimer) {
+ return;
+ }
+
+ this.autoScrollTimer = setInterval(() => {
+ this.autoAdvanceStack();
+ }, 3200);
+ }
+
+ onCarouselFocusOut(event: FocusEvent): void {
+ const next = event.relatedTarget as Node | null;
+ const shell = event.currentTarget as HTMLElement | null;
+
+ if (!shell?.contains(next)) {
+ this.resumeAutoScroll();
+ }
}
onLogoError(event: Event): void {
@@ -59,4 +102,27 @@ export class Home {
const wrapper = image?.closest('.skill-icon');
wrapper?.classList.add('is-fallback');
}
+
+ private autoAdvanceStack(): void {
+ const carousel = this.stackCarousel?.nativeElement;
+ if (!carousel) {
+ return;
+ }
+
+ const maxScroll = carousel.scrollWidth - carousel.clientWidth;
+ if (maxScroll <= 0) {
+ return;
+ }
+
+ if (carousel.scrollLeft >= maxScroll - 4) {
+ carousel.scrollTo({ left: 0, behavior: 'smooth' });
+ return;
+ }
+
+ carousel.scrollBy({ left: this.getScrollStep(carousel), behavior: 'smooth' });
+ }
+
+ private getScrollStep(carousel: HTMLElement): number {
+ return Math.max(carousel.clientWidth * 0.82, 260);
+ }
}