add carousel auto-scroll and removed inactive cursor
All checks were successful
publish.yml / publish (push) Successful in 1m3s

This commit is contained in:
2026-04-16 19:30:24 +02:00
parent eb1dd36195
commit 7032968fe2
6 changed files with 148 additions and 73 deletions

View File

@@ -16,34 +16,3 @@
height: 100%; height: 100%;
display: block; 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;
}
}

View File

@@ -1,11 +1,3 @@
<div class="background-layer" aria-hidden="true"> <div class="background-layer" aria-hidden="true">
<canvas #canvas class="particle-canvas"></canvas> <canvas #canvas class="particle-canvas"></canvas>
@if (isCursorVisible) {
<div
class="custom-cursor"
[class.pointer-down]="isPointerDown"
[style.transform]="cursorTransform"
></div>
}
</div> </div>

View File

@@ -1,5 +1,4 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
type PointerState = { type PointerState = {
x: number; x: number;
@@ -277,11 +276,8 @@ class ParticleEngine {
}) })
export class InteractiveBackground implements AfterViewInit, OnDestroy { export class InteractiveBackground implements AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly router = inject(Router);
isCursorVisible = InteractiveBackground.detectFinePointer();
isPointerDown = false; isPointerDown = false;
cursorTransform = 'translate3d(-9999px, -9999px, 0)';
private particleEngine: ParticleEngine | null = null; private particleEngine: ParticleEngine | null = null;
private destroyCallbacks: Array<() => void> = []; private destroyCallbacks: Array<() => void> = [];
@@ -293,6 +289,7 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
private activeTouchId: number | null = null; private activeTouchId: number | null = null;
private reducedMotion = false; private reducedMotion = false;
private mediaQuery!: MediaQueryList; private mediaQuery!: MediaQueryList;
private readonly pointerExcludeSelector = '[data-disable-bg-pointer]';
ngAfterViewInit(): void { ngAfterViewInit(): void {
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
@@ -332,11 +329,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
const onTouchCancel = () => this.handlePointerLeave(); const onTouchCancel = () => this.handlePointerLeave();
const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches); 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); window.addEventListener('resize', onResize);
@@ -375,7 +367,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
() => window.removeEventListener('touchend', onTouchEnd), () => window.removeEventListener('touchend', onTouchEnd),
() => window.removeEventListener('touchcancel', onTouchCancel), () => window.removeEventListener('touchcancel', onTouchCancel),
() => this.mediaQuery.removeEventListener('change', onMotionChange), () => this.mediaQuery.removeEventListener('change', onMotionChange),
() => routerSubscription.unsubscribe(),
]; ];
} }
@@ -435,6 +426,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
private handlePointerMove(event: PointerEvent): void { private handlePointerMove(event: PointerEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) { if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
return; return;
} }
@@ -443,6 +439,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
private handlePointerDown(event: PointerEvent): void { private handlePointerDown(event: PointerEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
if (event.pointerType === 'touch') { if (event.pointerType === 'touch') {
this.activeTouchId = event.pointerId; this.activeTouchId = event.pointerId;
} }
@@ -452,6 +453,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
private handlePointerUp(event: PointerEvent): void { private handlePointerUp(event: PointerEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
this.isPointerDown = false; this.isPointerDown = false;
if (event.pointerType === 'touch') { if (event.pointerType === 'touch') {
@@ -460,27 +466,42 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
this.activeTouchId = null; this.activeTouchId = null;
this.resetPointerInteraction(); this.pointerActive = false;
return; return;
} }
this.resetPointerInteraction(); this.updatePointer(event.clientX, event.clientY, true);
} }
private handleMouseMove(event: MouseEvent): void { private handleMouseMove(event: MouseEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
this.updatePointer(event.clientX, event.clientY, true); this.updatePointer(event.clientX, event.clientY, true);
} }
private handleMouseDown(event: MouseEvent): void { private handleMouseDown(event: MouseEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
this.isPointerDown = true; this.isPointerDown = true;
this.updatePointer(event.clientX, event.clientY, true); this.updatePointer(event.clientX, event.clientY, true);
} }
private handleMouseUp(): void { private handleMouseUp(): void {
this.resetPointerInteraction(); this.isPointerDown = false;
} }
private handleTouchStart(event: TouchEvent): void { private handleTouchStart(event: TouchEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
const touch = event.changedTouches.item(0); const touch = event.changedTouches.item(0);
if (!touch) { if (!touch) {
return; return;
@@ -492,6 +513,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
private handleTouchMove(event: TouchEvent): void { private handleTouchMove(event: TouchEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
if (this.activeTouchId === null) { if (this.activeTouchId === null) {
return; return;
} }
@@ -505,6 +531,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
private handleTouchEnd(event: TouchEvent): void { private handleTouchEnd(event: TouchEvent): void {
if (this.isPointerExcluded(event.target)) {
this.handlePointerLeave();
return;
}
if (this.activeTouchId === null) { if (this.activeTouchId === null) {
return; return;
} }
@@ -515,25 +546,20 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
} }
this.isPointerDown = false; this.isPointerDown = false;
this.resetPointerInteraction(); this.activeTouchId = null;
this.pointerActive = false;
} }
private handlePointerLeave(): void { private handlePointerLeave(): void {
this.resetPointerInteraction();
}
private resetPointerInteraction(): void {
this.pointerActive = false; this.pointerActive = false;
this.isPointerDown = false; this.isPointerDown = false;
this.activeTouchId = null; this.activeTouchId = null;
this.cursorTransform = 'translate3d(-9999px, -9999px, 0)';
} }
private updatePointer(x: number, y: number, active: boolean): void { private updatePointer(x: number, y: number, active: boolean): void {
this.pointerX = x; this.pointerX = x;
this.pointerY = y; this.pointerY = y;
this.pointerActive = active; this.pointerActive = active;
this.cursorTransform = `translate3d(${x}px, ${y}px, 0)`;
} }
private findTouch(touches: TouchList, identifier: number): Touch | null { private findTouch(touches: TouchList, identifier: number): Touch | null {
@@ -547,6 +573,14 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
return null; return null;
} }
private isPointerExcluded(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
return target.closest(this.pointerExcludeSelector) !== null;
}
private syncPointer(): void { private syncPointer(): void {
this.particleEngine?.setPointer({ this.particleEngine?.setPointer({
x: this.pointerX, 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;
}
} }

View File

@@ -237,7 +237,19 @@ h2 {
background: rgba(33, 38, 45, 0.8); background: rgba(33, 38, 45, 0.8);
color: #e6edf3; color: #e6edf3;
cursor: pointer; 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 { .carousel-btn:hover {

View File

@@ -34,7 +34,16 @@
<h2>Tech Stack</h2> <h2>Tech Stack</h2>
<p>Primary technologies I use to deliver production-ready software.</p> <p>Primary technologies I use to deliver production-ready software.</p>
</div> </div>
<div class="stack-carousel-shell"> <div
class="stack-carousel-shell"
data-disable-bg-pointer
(mouseenter)="pauseAutoScroll()"
(mouseleave)="resumeAutoScroll()"
(focusin)="pauseAutoScroll()"
(focusout)="onCarouselFocusOut($event)"
(touchstart)="pauseAutoScroll()"
(touchend)="resumeAutoScroll()"
>
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)"> <button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
<span aria-hidden="true">&larr;</span> <span aria-hidden="true">&larr;</span>
</button> </button>

View File

@@ -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 { RouterLink } from '@angular/router';
import { CardComponent } from '../../components/card/card'; import { CardComponent } from '../../components/card/card';
import { PROJECTS, Project } from '../projects/project-data'; import { PROJECTS, Project } from '../projects/project-data';
@@ -17,8 +17,9 @@ interface Skill {
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.css', styleUrl: './home.css',
}) })
export class Home { export class Home implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>; @ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
private autoScrollTimer: ReturnType<typeof setInterval> | null = null;
readonly hero = { readonly hero = {
name: 'Tim Kainz', name: 'Tim Kainz',
@@ -44,14 +45,56 @@ export class Home {
readonly projects: Project[] = PROJECTS; 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 { scrollStack(direction: -1 | 1): void {
const carousel = this.stackCarousel?.nativeElement; const carousel = this.stackCarousel?.nativeElement;
if (!carousel) { if (!carousel) {
return; return;
} }
const cardWidth = Math.max(carousel.clientWidth * 0.82, 260); carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
carousel.scrollBy({ left: cardWidth * 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 { onLogoError(event: Event): void {
@@ -59,4 +102,27 @@ export class Home {
const wrapper = image?.closest('.skill-icon'); const wrapper = image?.closest('.skill-icon');
wrapper?.classList.add('is-fallback'); 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);
}
} }