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%;
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">
<canvas #canvas class="particle-canvas"></canvas>
@if (isCursorVisible) {
<div
class="custom-cursor"
[class.pointer-down]="isPointerDown"
[style.transform]="cursorTransform"
></div>
}
</div>

View File

@@ -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<HTMLCanvasElement>;
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;
}
}

View File

@@ -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 {

View File

@@ -34,7 +34,16 @@
<h2>Tech Stack</h2>
<p>Primary technologies I use to deliver production-ready software.</p>
</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)">
<span aria-hidden="true">&larr;</span>
</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 { 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<HTMLElement>;
private autoScrollTimer: ReturnType<typeof setInterval> | 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);
}
}