fix(andreknie.de): keep testimonial carousel centered

This commit is contained in:
2026-07-27 19:48:29 +02:00
parent cfbd77a1a4
commit 772a63c622
2 changed files with 73 additions and 31 deletions
@@ -35,7 +35,8 @@
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
gap: 24px;
padding-bottom: 32px; /* Space for scrollbar and shadow */
padding: 0 calc((100% - min(100%, var(--container-max))) / 2) 32px;
scroll-padding-inline: calc((100% - 88%) / 2);
cursor: grab;
user-select: none;
@@ -57,7 +58,7 @@
.testimonial-card {
/* On mobile, show ~85% width so the next card peeks in */
flex: 0 0 calc(88% - 24px);
scroll-snap-align: start;
scroll-snap-align: center;
/* Equal heights trick: cards stretch to the tallest sibling */
display: flex;
@@ -70,6 +71,10 @@
}
@media (min-width: 768px) {
.testimonial-scroll-container {
scroll-padding-inline: calc((100% - 60%) / 2);
}
.testimonial-card {
/* On tablet, show ~60% width */
flex: 0 0 calc(60% - 24px);
@@ -77,6 +82,10 @@
}
@media (min-width: 1024px) {
.testimonial-scroll-container {
scroll-padding-inline: calc((100% - 45%) / 2);
}
.testimonial-card {
/* On desktop, show ~45% width */
flex: 0 0 calc(45% - 24px);
@@ -1,7 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useLayoutEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css'
const LOOP_SETS = 9
const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
/**
* Manual testimonial slider (carousel).
* Equal heights, horizontal scroll, no autoplay.
@@ -11,49 +14,80 @@ export default function TestimonialSlider({ testimonials = [] }) {
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const [isDragging, setIsDragging] = useState(false)
const canLoop = testimonials.length > 1
const visibleTestimonials = canLoop ? [...testimonials, ...testimonials, ...testimonials] : testimonials
const visibleTestimonials = canLoop
? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
: testimonials
const getCardStep = () => {
const getCenteredScrollLeft = (cardIndex) => {
const scroller = scrollerRef.current
const firstCard = scroller?.querySelector('.testimonial-card')
if (!scroller || !firstCard) return 0
const card = scroller?.querySelectorAll('.testimonial-card')[cardIndex]
if (!scroller || !card) return 0
const gap = parseFloat(window.getComputedStyle(scroller).columnGap || '0')
return firstCard.offsetWidth + gap
return card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2
}
useEffect(() => {
const getClosestCardIndex = () => {
const scroller = scrollerRef.current
if (!canLoop || !scroller) return
const cards = Array.from(scroller?.querySelectorAll('.testimonial-card') || [])
if (!scroller || cards.length === 0) return 0
const cardStep = getCardStep()
if (!cardStep) return
const scrollerCenter = scroller.scrollLeft + scroller.clientWidth / 2
return cards.reduce((closestIndex, card, index) => {
const cardCenter = card.offsetLeft + card.offsetWidth / 2
const closestCard = cards[closestIndex]
const closestCenter = closestCard.offsetLeft + closestCard.offsetWidth / 2
scroller.scrollLeft = cardStep * testimonials.length
return Math.abs(cardCenter - scrollerCenter) < Math.abs(closestCenter - scrollerCenter)
? index
: closestIndex
}, 0)
}
const centerCard = (cardIndex, behavior = 'auto') => {
const scroller = scrollerRef.current
if (!scroller) return
scroller.scrollTo({
left: getCenteredScrollLeft(cardIndex),
behavior,
})
}
useLayoutEffect(() => {
if (!canLoop) return undefined
const frameId = window.requestAnimationFrame(() => {
const scroller = scrollerRef.current
const card = scroller?.querySelectorAll('.testimonial-card')[testimonials.length * MIDDLE_SET]
if (!scroller || !card) return
scroller.scrollLeft = card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2
})
return () => window.cancelAnimationFrame(frameId)
}, [canLoop, testimonials.length])
const scrollByCard = (direction) => {
const scroller = scrollerRef.current
const cardStep = getCardStep()
if (!scroller || !cardStep) return
if (!scroller) return
scroller.scrollBy({
left: direction * cardStep,
behavior: 'smooth',
})
const targetIndex = getClosestCardIndex() + direction
centerCard(targetIndex, 'smooth')
window.setTimeout(recenterIfNeeded, 450)
}
const keepLoopCentered = () => {
const recenterIfNeeded = () => {
const scroller = scrollerRef.current
const cardStep = getCardStep()
if (!canLoop || !scroller || !cardStep) return
if (!canLoop || !scroller) return
const segmentWidth = cardStep * testimonials.length
if (scroller.scrollLeft < segmentWidth * 0.5) {
scroller.scrollLeft += segmentWidth
} else if (scroller.scrollLeft > segmentWidth * 1.5) {
scroller.scrollLeft -= segmentWidth
}
const currentIndex = getClosestCardIndex()
const lowBoundary = testimonials.length
const highBoundary = testimonials.length * (LOOP_SETS - 1)
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
}
const handlePointerDown = (event) => {
@@ -82,7 +116,7 @@ export default function TestimonialSlider({ testimonials = [] }) {
const scroller = scrollerRef.current
dragStateRef.current.isDragging = false
setIsDragging(false)
keepLoopCentered()
recenterIfNeeded()
if (scroller?.hasPointerCapture?.(event.pointerId)) {
scroller.releasePointerCapture(event.pointerId)
@@ -108,7 +142,6 @@ export default function TestimonialSlider({ testimonials = [] }) {
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onScroll={keepLoopCentered}
onPointerLeave={(event) => {
if (dragStateRef.current.isDragging) stopDragging(event)
}}