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-snap-type: x mandatory;
scroll-behavior: smooth; scroll-behavior: smooth;
gap: 24px; 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; cursor: grab;
user-select: none; user-select: none;
@@ -57,7 +58,7 @@
.testimonial-card { .testimonial-card {
/* On mobile, show ~85% width so the next card peeks in */ /* On mobile, show ~85% width so the next card peeks in */
flex: 0 0 calc(88% - 24px); flex: 0 0 calc(88% - 24px);
scroll-snap-align: start; scroll-snap-align: center;
/* Equal heights trick: cards stretch to the tallest sibling */ /* Equal heights trick: cards stretch to the tallest sibling */
display: flex; display: flex;
@@ -70,6 +71,10 @@
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.testimonial-scroll-container {
scroll-padding-inline: calc((100% - 60%) / 2);
}
.testimonial-card { .testimonial-card {
/* On tablet, show ~60% width */ /* On tablet, show ~60% width */
flex: 0 0 calc(60% - 24px); flex: 0 0 calc(60% - 24px);
@@ -77,6 +82,10 @@
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.testimonial-scroll-container {
scroll-padding-inline: calc((100% - 45%) / 2);
}
.testimonial-card { .testimonial-card {
/* On desktop, show ~45% width */ /* On desktop, show ~45% width */
flex: 0 0 calc(45% - 24px); 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 { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css' import './TestimonialSlider.css'
const LOOP_SETS = 9
const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
/** /**
* Manual testimonial slider (carousel). * Manual testimonial slider (carousel).
* Equal heights, horizontal scroll, no autoplay. * 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 dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const canLoop = testimonials.length > 1 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 scroller = scrollerRef.current
const firstCard = scroller?.querySelector('.testimonial-card') const card = scroller?.querySelectorAll('.testimonial-card')[cardIndex]
if (!scroller || !firstCard) return 0 if (!scroller || !card) return 0
const gap = parseFloat(window.getComputedStyle(scroller).columnGap || '0') return card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2
return firstCard.offsetWidth + gap
} }
useEffect(() => { const getClosestCardIndex = () => {
const scroller = scrollerRef.current 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() const scrollerCenter = scroller.scrollLeft + scroller.clientWidth / 2
if (!cardStep) return 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]) }, [canLoop, testimonials.length])
const scrollByCard = (direction) => { const scrollByCard = (direction) => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
const cardStep = getCardStep() if (!scroller) return
if (!scroller || !cardStep) return
scroller.scrollBy({ const targetIndex = getClosestCardIndex() + direction
left: direction * cardStep, centerCard(targetIndex, 'smooth')
behavior: 'smooth',
}) window.setTimeout(recenterIfNeeded, 450)
} }
const keepLoopCentered = () => { const recenterIfNeeded = () => {
const scroller = scrollerRef.current const scroller = scrollerRef.current
const cardStep = getCardStep() if (!canLoop || !scroller) return
if (!canLoop || !scroller || !cardStep) return
const segmentWidth = cardStep * testimonials.length const currentIndex = getClosestCardIndex()
if (scroller.scrollLeft < segmentWidth * 0.5) { const lowBoundary = testimonials.length
scroller.scrollLeft += segmentWidth const highBoundary = testimonials.length * (LOOP_SETS - 1)
} else if (scroller.scrollLeft > segmentWidth * 1.5) { if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
scroller.scrollLeft -= segmentWidth
} const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
} }
const handlePointerDown = (event) => { const handlePointerDown = (event) => {
@@ -82,7 +116,7 @@ export default function TestimonialSlider({ testimonials = [] }) {
const scroller = scrollerRef.current const scroller = scrollerRef.current
dragStateRef.current.isDragging = false dragStateRef.current.isDragging = false
setIsDragging(false) setIsDragging(false)
keepLoopCentered() recenterIfNeeded()
if (scroller?.hasPointerCapture?.(event.pointerId)) { if (scroller?.hasPointerCapture?.(event.pointerId)) {
scroller.releasePointerCapture(event.pointerId) scroller.releasePointerCapture(event.pointerId)
@@ -108,7 +142,6 @@ export default function TestimonialSlider({ testimonials = [] }) {
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={stopDragging} onPointerUp={stopDragging}
onPointerCancel={stopDragging} onPointerCancel={stopDragging}
onScroll={keepLoopCentered}
onPointerLeave={(event) => { onPointerLeave={(event) => {
if (dragStateRef.current.isDragging) stopDragging(event) if (dragStateRef.current.isDragging) stopDragging(event)
}} }}