Skip to content

Commit 256a3db

Browse files
committed
feat: replace marquee with carousel, add mobile responsiveness
1 parent 6f44f3d commit 256a3db

9 files changed

Lines changed: 180 additions & 155 deletions

File tree

components.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,5 @@
1818
"lib": "@/lib",
1919
"hooks": "@/hooks"
2020
},
21-
"registries": {
22-
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
23-
}
21+
"registries": {}
2422
}

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"cloudinary": "^2.8.0",
2424
"clsx": "^2.1.1",
2525
"embla-carousel": "^8.6.0",
26+
"embla-carousel-auto-height": "^8.6.0",
2627
"embla-carousel-react": "^8.6.0",
2728
"framer-motion": "^12.23.6",
2829
"lucide-react": "^0.539.0",

src/app/globals.css

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,6 @@
2222

2323
--text-body: rgba(250, 250, 250, 0.562);
2424
--detail-medium-contrast: rgba(89, 83, 172, 0.452);
25-
26-
--animate-scroll: scroll var(--animation-duration, 40s)
27-
var(--animation-direction, forwards) linear infinite;
28-
29-
@keyframes scroll {
30-
to {
31-
transform: translate(calc(-50% - 0.5rem));
32-
}
33-
}
3425
}
3526

3627
/* @custom-variant dark (&:where(.dark, .dark *)); */

src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Footer,
1313
FAQ,
1414
Gallery,
15+
Testimonials,
1516
} from "@/components";
1617

1718
const Home = () => {
@@ -32,6 +33,7 @@ const Home = () => {
3233
<Hero />
3334
</div>
3435
</div>
36+
<Testimonials />
3537
<About />
3638
<Gallery />
3739
<Sponsors />
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
'use client'
2+
3+
import { useState, useEffect, useCallback, useRef } from 'react'
4+
import Image from 'next/image'
5+
import { motion } from 'framer-motion'
6+
import useEmblaCarousel from 'embla-carousel-react'
7+
import AutoHeight from 'embla-carousel-auto-height'
8+
import type { EmblaPluginType } from 'embla-carousel'
9+
import {
10+
PrevButton,
11+
NextButton,
12+
usePrevNextButtons
13+
} from '../gallery/embla/EmblaCarouselArrowButtons'
14+
import { useDotButton } from '../gallery/embla/EmblaCarouselDotButtons'
15+
import { cn } from '@/lib/utils'
16+
import testimonials from './testimonials.json'
17+
import '../../../app/embla.css'
18+
19+
const CARD_BASE =
20+
'rounded-2xl border-4 border-white/10 backdrop-blur-xs px-8 py-8 md:px-12 md:py-10 flex flex-col gap-6 transition-all duration-300 cursor-default'
21+
const CARD_ACTIVE =
22+
'bg-light-mode/10 hover:scale-[1.010] hover:shadow-[0_0_26px_rgba(245,240,233,0.1)]'
23+
const CARD_INACTIVE = 'bg-light-mode/5 opacity-50'
24+
25+
const KOI_STYLE = {
26+
bottom: '-23vw',
27+
x: '-200%',
28+
rotate: 80,
29+
height: '35vw',
30+
minHeight: '140px',
31+
minWidth: '140px',
32+
width: '15vw',
33+
maxHeight: '520px',
34+
maxWidth: '520px'
35+
}
36+
37+
const Testimonials: React.FC = () => {
38+
const [plugins, setPlugins] = useState<EmblaPluginType[]>([])
39+
40+
useEffect(() => {
41+
if (window.matchMedia('(max-width: 767px)').matches) {
42+
setPlugins([AutoHeight()])
43+
}
44+
}, [])
45+
46+
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: 'center' }, plugins)
47+
const { selectedIndex } = useDotButton(emblaApi)
48+
const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick } =
49+
usePrevNextButtons(emblaApi)
50+
51+
// Track viewport height via ResizeObserver so nav buttons follow the
52+
// AutoHeight animation in real-time (top: 50% doesn't trigger CSS transitions)
53+
const viewportElRef = useRef<HTMLDivElement | null>(null)
54+
const [btnTop, setBtnTop] = useState<number | undefined>(undefined)
55+
56+
const setViewportRef = useCallback(
57+
(node: HTMLDivElement | null) => {
58+
viewportElRef.current = node
59+
emblaRef(node)
60+
},
61+
[emblaRef]
62+
)
63+
64+
useEffect(() => {
65+
const el = viewportElRef.current
66+
if (!el) return
67+
setBtnTop(el.offsetHeight / 2)
68+
const ro = new ResizeObserver(() => setBtnTop(el.offsetHeight / 2))
69+
ro.observe(el)
70+
return () => ro.disconnect()
71+
}, [emblaApi])
72+
73+
return (
74+
<div className='relative mt-[25vh] md:mt-[35vh]'>
75+
<div
76+
id='Testimonials'
77+
className='max-w-[1800px] w-full flex flex-col items-center mx-auto'
78+
>
79+
<div className='w-full flex justify-center text-center mb-10'>
80+
<h2 className='text-3xl md:text-5xl xl:text-6xl font-bold pb-4 border-b-1 text-light-mode'>
81+
Why Join The Team
82+
</h2>
83+
</div>
84+
85+
<div className='relative w-full md:max-w-[75vw]'>
86+
{/* edge fade overlays */}
87+
<div className='absolute inset-y-0 left-0 w-[18%] z-10 pointer-events-none bg-gradient-to-r from-sea to-transparent' />
88+
<div className='absolute inset-y-0 right-0 w-[18%] z-10 pointer-events-none bg-gradient-to-l from-sea to-transparent' />
89+
90+
{/* nav buttons — top set via JS so they track the AutoHeight animation */}
91+
<div
92+
className='absolute left-2 -translate-y-1/2 z-20 rounded-full bg-sea/70 backdrop-blur-md'
93+
style={{ top: btnTop ?? '50%' }}
94+
>
95+
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} />
96+
</div>
97+
<div
98+
className='absolute right-2 -translate-y-1/2 z-20 rounded-full bg-sea/70 backdrop-blur-md'
99+
style={{ top: btnTop ?? '50%' }}
100+
>
101+
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} />
102+
</div>
103+
104+
<div
105+
className='overflow-hidden py-5 transition-[height] duration-300 ease-in-out'
106+
ref={setViewportRef}
107+
>
108+
<div className='flex items-center [touch-action:pan-y_pinch-zoom] -ml-6'>
109+
{testimonials.map((item, idx) => (
110+
<div key={idx} className='flex-[0_0_85%] md:flex-[0_0_70%] min-w-0 pl-6'>
111+
<div className={cn(CARD_BASE, idx === selectedIndex ? CARD_ACTIVE : CARD_INACTIVE)}>
112+
<p className='text-sm md:text-base leading-[1.6] text-light-mode'>
113+
{item.quote}
114+
</p>
115+
<div className='flex flex-col gap-1'>
116+
<span className='text-sm font-medium text-light-mode/60'>{item.name}</span>
117+
{item.title && (
118+
<span className='text-xs text-light-mode/40 whitespace-pre-line'>
119+
{item.title}
120+
</span>
121+
)}
122+
</div>
123+
</div>
124+
</div>
125+
))}
126+
</div>
127+
</div>
128+
</div>
129+
</div>
130+
131+
{/* decorative koi for desktop only */}
132+
<motion.div
133+
className='hidden md:block absolute left-1/2 z-10'
134+
style={KOI_STYLE}
135+
animate={{ y: ['0vw', '-0.8vw', '0vw'] }}
136+
transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }}
137+
>
138+
<div className='relative w-full h-full'>
139+
<Image src='/images/koi2.svg' alt='Koi' fill className='object-contain opacity-80' />
140+
</div>
141+
</motion.div>
142+
</div>
143+
)
144+
}
145+
146+
export default Testimonials

0 commit comments

Comments
 (0)