Last active
March 29, 2026 18:36
-
-
Save entity/4e0b0f6fb32b5866deaef2ae8d27828f to your computer and use it in GitHub Desktop.
3D Carousel with CSS scroll-driven animations + CSS variable fallback
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* Destinations carousel — fallback: all transforms derived from a single --si CSS variable */ | |
| .carousel-card { | |
| --raw: calc(var(--i) - var(--si)); | |
| --pos: calc(mod(var(--raw) + var(--half-n), var(--n)) - var(--half-n)); | |
| --abs: abs(var(--pos)); | |
| --sgn: sign(var(--pos)); | |
| /* scale: 1 → 0.8 → 0.7 → 0.6 (piecewise linear) */ | |
| --s: max(0.6, max(1 - 0.2 * var(--abs), 0.9 - 0.1 * var(--abs))); | |
| /* rotateY: linear cap at 25deg */ | |
| --ry: calc(var(--sgn) * -1deg * min(25, var(--abs) * 21)); | |
| /* z-index: 30 → 20 → 10 → 5 */ | |
| --z: round(max(5, max(30 - 10 * var(--abs), 20 - 5 * var(--abs))), 1); | |
| /* opacity: 1 → 1 → 0.6 → 0 */ | |
| --co: clamp(0, min(1, min(1.4 - 0.4 * var(--abs), 1.8 - 0.6 * var(--abs))), 1); | |
| /* translateY: 0 → 20 → 40 → 50 (cards drop as they move from center) */ | |
| --ty: calc(1px * min(50, min(var(--abs) * 20, 20 + var(--abs) * 10))); | |
| transform: perspective(900px) scale(var(--s)) rotateY(var(--ry)) translateY(var(--ty)); | |
| z-index: var(--z); | |
| opacity: var(--co); | |
| pointer-events: none; | |
| &[data-visible] { | |
| pointer-events: auto; | |
| } | |
| } | |
| /* Destinations carousel — scroll-driven animation (compositor thread, no JS per frame) */ | |
| @supports (animation-timeline: view()) { | |
| .carousel-card { | |
| animation: carousel-3d linear both; | |
| animation-timeline: view(inline); | |
| transform: none; | |
| z-index: auto; | |
| opacity: 1; | |
| } | |
| @keyframes carousel-3d { | |
| 0%, 18% { | |
| transform: perspective(900px) scale(0.6) rotateY(-15deg) translateY(50px); | |
| opacity: 0; | |
| z-index: 5; | |
| } | |
| 29% { | |
| transform: perspective(900px) scale(0.75) rotateY(-15deg) translateY(25px); | |
| opacity: 0.6; | |
| z-index: 10; | |
| } | |
| 39% { | |
| transform: perspective(900px) scale(0.85) rotateY(-12deg) translateY(15px); | |
| opacity: 1; | |
| z-index: 20; | |
| } | |
| 50% { | |
| transform: perspective(900px) scale(1) rotateY(0deg) translateY(0px); | |
| opacity: 1; | |
| z-index: 30; | |
| } | |
| 61% { | |
| transform: perspective(900px) scale(0.85) rotateY(12deg) translateY(15px); | |
| opacity: 1; | |
| z-index: 20; | |
| } | |
| 71% { | |
| transform: perspective(900px) scale(0.75) rotateY(15deg) translateY(25px); | |
| opacity: 0.6; | |
| z-index: 10; | |
| } | |
| 82%, 100% { | |
| transform: perspective(900px) scale(0.6) rotateY(15deg) translateY(50px); | |
| opacity: 0; | |
| z-index: 5; | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Link } from '@inertiajs/react'; | |
| import { useDialKit } from 'dialkit'; | |
| import { AnimatePresence, m } from 'motion/react'; | |
| import { type PropsWithChildren, startTransition, useCallback, useEffect, useRef, useState } from 'react'; | |
| import Flag from '@/components/flag'; | |
| import { ArrowLeft02Icon, ArrowRight02StrokeIcon, HugeiconsIcon } from '@/components/icons'; | |
| import { asset, responsiveSrcSet } from '@/utils/common'; | |
| interface Country { | |
| name: string; | |
| slug: string; | |
| code: string; | |
| description?: string; | |
| } | |
| interface PopularDestinationsCarouselProps { | |
| countries: Country[]; | |
| } | |
| const CARD_WIDTH = 260; | |
| const CARD_SPACING = 200; // effective scroll distance per card | |
| const CARD_OVERLAP = CARD_WIDTH - CARD_SPACING; // 60px overlap via negative margin | |
| const COPIES = 3; | |
| const easeOutQuad = [0.25, 0.46, 0.45, 0.94] as const; | |
| export default function PopularDestinationsCarousel({ countries }: PopularDestinationsCarouselProps) { | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); | |
| const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map()); | |
| const dotRefs = useRef<Map<number, HTMLDivElement>>(new Map()); | |
| const lastRoundedRef = useRef(-1); | |
| const total = countries.length; | |
| const totalCards = total * COPIES; | |
| const centerIndex = Math.floor(total / 2); | |
| const scrollIndexRef = useRef(total + centerIndex); | |
| const needsFallback = useRef(false); | |
| const [settledIndex, setSettledIndex] = useState(centerIndex); | |
| const activeCountry = countries[settledIndex] ?? countries[0]; | |
| const handleScroll = useCallback(() => { | |
| const el = scrollRef.current; | |
| if (!el) return; | |
| const rawIndex = el.scrollLeft / CARD_SPACING; | |
| scrollIndexRef.current = rawIndex; | |
| // Only update --si for browsers without scroll-driven animations | |
| if (needsFallback.current) { | |
| el.style.setProperty('--si', String(rawIndex)); | |
| } | |
| // Update active dot + card inert state | |
| const countryIdx = ((Math.round(rawIndex) % total) + total) % total; | |
| if (countryIdx !== lastRoundedRef.current) { | |
| dotRefs.current.get(lastRoundedRef.current)?.removeAttribute('data-active'); | |
| dotRefs.current.get(countryIdx)?.setAttribute('data-active', ''); | |
| // Only update changed cards (old center + new center) | |
| for (const [absIdx, cardEl] of cardRefs.current) { | |
| const ci = absIdx % total; | |
| if (ci === lastRoundedRef.current) { | |
| cardEl.setAttribute('inert', ''); | |
| cardEl.removeAttribute('data-visible'); | |
| cardEl.removeAttribute('data-center'); | |
| } else if (ci === countryIdx) { | |
| cardEl.removeAttribute('inert'); | |
| cardEl.setAttribute('data-visible', ''); | |
| cardEl.setAttribute('data-center', ''); | |
| } | |
| } | |
| lastRoundedRef.current = countryIdx; | |
| } | |
| clearTimeout(scrollTimeoutRef.current); | |
| scrollTimeoutRef.current = setTimeout(() => { | |
| const currentRaw = el.scrollLeft / CARD_SPACING; | |
| const settled = ((Math.round(currentRaw) % total) + total) % total; | |
| startTransition(() => setSettledIndex(settled)); | |
| }, 200); | |
| }, [total]); | |
| useEffect(() => { | |
| const el = scrollRef.current; | |
| if (!el) return; | |
| needsFallback.current = !CSS.supports('animation-timeline', 'view()'); | |
| const startIndex = total + centerIndex; | |
| el.scrollTo({ left: startIndex * CARD_SPACING }); | |
| el.style.setProperty('--si', String(startIndex)); | |
| lastRoundedRef.current = centerIndex; | |
| el.addEventListener('scroll', handleScroll, { passive: true }); | |
| return () => el.removeEventListener('scroll', handleScroll); | |
| }, [handleScroll, centerIndex, total]); | |
| const goToPrevious = () => { | |
| const el = scrollRef.current; | |
| if (!el) return; | |
| const current = Math.round(el.scrollLeft / CARD_SPACING); | |
| el.scrollTo({ left: (current - 1) * CARD_SPACING, behavior: 'smooth' }); | |
| }; | |
| const goToNext = () => { | |
| const el = scrollRef.current; | |
| if (!el) return; | |
| const current = Math.round(el.scrollLeft / CARD_SPACING); | |
| el.scrollTo({ left: (current + 1) * CARD_SPACING, behavior: 'smooth' }); | |
| }; | |
| const scrollToIndex = (targetCountryIndex: number, smooth = true) => { | |
| const el = scrollRef.current; | |
| if (!el) return; | |
| const currentCountryIdx = ((Math.round(scrollIndexRef.current) % total) + total) % total; | |
| let delta = targetCountryIndex - currentCountryIdx; | |
| if (delta > total / 2) delta -= total; | |
| if (delta < -total / 2) delta += total; | |
| el.scrollBy({ left: Math.round(delta * CARD_SPACING), behavior: smooth ? 'smooth' : 'instant' }); | |
| }; | |
| const handleCardClick = (e: React.MouseEvent<HTMLDivElement>) => { | |
| const card = e.currentTarget; | |
| const countryIdx = parseInt(card.dataset.countryIndex!, 10); | |
| if (countryIdx !== settledIndex) { | |
| e.preventDefault(); | |
| scrollToIndex(countryIdx); | |
| } | |
| }; | |
| const params = useDialKit( | |
| 'Carousel', | |
| { | |
| descDuration: [0.2, 0.05, 1], | |
| prev: { type: 'action' as const }, | |
| next: { type: 'action' as const }, | |
| }, | |
| { | |
| onAction: (action) => { | |
| if (action === 'prev') goToPrevious(); | |
| if (action === 'next') goToNext(); | |
| }, | |
| }, | |
| ); | |
| if (countries.length === 0) return null; | |
| return ( | |
| <section className="bg-grey-25 relative overflow-hidden py-10 lg:mx-4 lg:max-w-7xl lg:rounded-[25px] lg:py-14 xl:mx-auto"> | |
| <div> | |
| {/* Header */} | |
| <div className="mb-8 flex flex-col items-center gap-3 px-4 lg:mb-12"> | |
| <h2 className="text-grey-450 text-center text-2xl font-medium lg:text-3xl"> | |
| Popular Beach Holiday <span className="text-brand-500">Destinations</span> for {new Date().getFullYear()}/{new Date().getFullYear() + 1} | |
| </h2> | |
| <p className="text-grey-300 max-w-md text-center text-sm">Browse our most popular beach destinations for UK travellers</p> | |
| </div> | |
| <div className="relative"> | |
| {/* Navigation Arrows */} | |
| <button | |
| onClick={goToPrevious} | |
| className="bg-grey-50 text-grey-400 hover:bg-grey-100 hover:text-grey-450 absolute top-1/2 left-0 z-40 hidden size-10 -translate-y-1/2 items-center justify-center rounded-full transition-colors lg:left-4 lg:flex lg:size-12" | |
| aria-label="Previous destination" | |
| > | |
| <HugeiconsIcon icon={ArrowLeft02Icon} className="size-5" /> | |
| </button> | |
| <button | |
| onClick={goToNext} | |
| className="bg-grey-50 text-grey-400 hover:bg-grey-100 hover:text-grey-450 absolute top-1/2 right-0 z-40 hidden size-10 -translate-y-1/2 items-center justify-center rounded-full transition-colors lg:right-4 lg:flex lg:size-12" | |
| aria-label="Next destination" | |
| > | |
| <HugeiconsIcon icon={ArrowRight02StrokeIcon} className="size-5" /> | |
| </button> | |
| {/* Scroll container — cards are real scroll children so view(inline) can track them */} | |
| <div | |
| ref={scrollRef} | |
| className="scrollbar-none relative mx-auto flex h-[380px] snap-x snap-mandatory items-center overflow-x-auto lg:h-[420px]" | |
| style={ | |
| { | |
| '--si': total + centerIndex, | |
| '--n': totalCards, | |
| '--half-n': totalCards / 2, | |
| } as React.CSSProperties | |
| } | |
| > | |
| <div style={{ minWidth: `calc(50% - ${CARD_WIDTH / 2}px)` }} className="shrink-0" /> | |
| {Array.from({ length: COPIES }, (_, copy) => | |
| countries.map((country, i) => { | |
| const absoluteIndex = copy * total + i; | |
| const isCenter = i === settledIndex; | |
| return ( | |
| <div | |
| key={`${copy}-${country.slug}`} | |
| ref={(el) => { | |
| if (el) cardRefs.current.set(absoluteIndex, el); | |
| else cardRefs.current.delete(absoluteIndex); | |
| }} | |
| data-country-index={i} | |
| data-visible={isCenter || undefined} | |
| data-center={isCenter || undefined} | |
| inert={!isCenter || undefined} | |
| onClick={handleCardClick} | |
| className="carousel-card shrink-0 cursor-pointer snap-center snap-always" | |
| style={{ '--i': absoluteIndex, width: CARD_WIDTH, height: 340, marginRight: -CARD_OVERLAP } as React.CSSProperties} | |
| > | |
| <CardWrapper slug={country.slug}> | |
| <div className="absolute inset-0"> | |
| <img | |
| src={asset(`/img/countries/${country.slug}.webp`)} | |
| srcSet={responsiveSrcSet(`/img/countries/${country.slug}.webp`, [480, 800])} | |
| sizes="260px" | |
| alt={country.name} | |
| width={260} | |
| height={340} | |
| loading="lazy" | |
| className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" | |
| /> | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" /> | |
| <div className="absolute inset-x-0 bottom-0 p-4"> | |
| <h3 className="flex items-center gap-2 text-lg font-semibold text-white"> | |
| <Flag country={country.code} alt={country.name} className="size-5" /> | |
| {country.name} | |
| </h3> | |
| <p className="mt-1 text-xs text-white/70">Click to learn more</p> | |
| </div> | |
| </div> | |
| </CardWrapper> | |
| </div> | |
| ); | |
| }), | |
| )} | |
| <div style={{ minWidth: `calc(50% - ${CARD_WIDTH / 2}px)` }} className="shrink-0" /> | |
| </div> | |
| </div> | |
| {/* Description Section */} | |
| <AnimatePresence mode="wait"> | |
| <m.div key={activeCountry.slug} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: params.descDuration, ease: easeOutQuad }} className="mt-6 text-center"> | |
| <p className="text-grey-300 mx-auto min-h-[3lh] max-w-md px-4 text-sm">{activeCountry.description ?? `Discover the beauty of ${activeCountry.name}, a perfect destination for your next beach getaway.`}</p> | |
| </m.div> | |
| </AnimatePresence> | |
| {/* Pagination dots */} | |
| <div className="mt-6 flex justify-center gap-1.5"> | |
| {countries.map((country, index) => ( | |
| <div | |
| key={country.slug} | |
| ref={(el) => { | |
| if (el) dotRefs.current.set(index, el); | |
| else dotRefs.current.delete(index); | |
| }} | |
| data-active={index === settledIndex || undefined} | |
| className="bg-grey-100 data-active:bg-brand-500 h-1.5 w-1.5 rounded-full transition-all duration-200 data-active:w-4" | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| function CardWrapper({ slug, children }: PropsWithChildren<{ slug: string }>) { | |
| return ( | |
| <Link href={route('countries.show', slug)} tabIndex={-1} className="relative block h-full w-full overflow-hidden rounded-[25px] bg-white shadow"> | |
| {children} | |
| </Link> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment