Skip to content

Commit 5e0279c

Browse files
davidagustinclaude
andcommitted
fix: rewrite carousel as continuous sliding container for smooth navigation
Replace AnimatePresence-based carousel (which caused jank from DOM mount/unmount) with a sliding container that renders all cards in a single flex row and animates translateX with spring physics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent afd1d7e commit 5e0279c

File tree

1 file changed

+85
-103
lines changed

1 file changed

+85
-103
lines changed

src/components/Projects.tsx

Lines changed: 85 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -133,22 +133,21 @@ const CarouselView: React.FC<{
133133
columns: 1 | 2 | 3;
134134
onSelect: (p: Project) => void;
135135
}> = ({ projects, columns, onSelect }) => {
136-
const [pageIndex, setPageIndex] = useState(0);
137-
const [direction, setDirection] = useState(0);
136+
const [currentIndex, setCurrentIndex] = useState(0);
137+
const containerRef = useRef<HTMLDivElement>(null);
138138

139-
const maxStart = Math.max(0, projects.length - columns);
139+
const maxIndex = Math.max(0, projects.length - columns);
140140

141-
// Reset page when filter or columns change
141+
// Reset when filter or columns change
142142
useEffect(() => {
143-
setPageIndex(0);
143+
setCurrentIndex(0);
144144
}, [projects.length, columns]);
145145

146146
const go = useCallback(
147147
(dir: 1 | -1) => {
148-
setDirection(dir);
149-
setPageIndex((prev) => (prev + dir + maxStart + 1) % (maxStart + 1));
148+
setCurrentIndex((prev) => Math.max(0, Math.min(maxIndex, prev + dir)));
150149
},
151-
[maxStart]
150+
[maxIndex]
152151
);
153152

154153
useEffect(() => {
@@ -162,132 +161,115 @@ const CarouselView: React.FC<{
162161

163162
if (projects.length === 0) return null;
164163

165-
// Get current window of projects (one card shift at a time)
166-
const pageProjects = projects.slice(pageIndex, pageIndex + columns);
167-
168-
const variants = {
169-
enter: (d: number) => ({ x: d > 0 ? 150 : -150, opacity: 0 }),
170-
center: { x: 0, opacity: 1 },
171-
exit: (d: number) => ({ x: d > 0 ? -150 : 150, opacity: 0 }),
172-
};
173-
174-
const gridClass =
175-
columns === 1
176-
? 'grid-cols-1 max-w-2xl mx-auto'
177-
: columns === 2
178-
? 'grid-cols-1 md:grid-cols-2'
179-
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
164+
// Each card takes 1/columns of the container width, with gap
165+
const gapPx = 24;
166+
const cardPercent = 100 / columns;
167+
const offsetPercent = -(currentIndex * cardPercent);
180168

181169
return (
182170
<div className="flex flex-col items-center">
183-
<div className={`relative w-full ${columns === 1 ? 'max-w-2xl' : ''}`}>
171+
<div className="relative w-full">
184172
{/* Nav arrows */}
185173
<button
186174
type="button"
187175
onClick={() => go(-1)}
188-
className={`absolute ${columns === 1 ? 'left-0' : '-left-4 sm:-left-12'} top-1/2 -translate-y-1/2 ${columns === 1 ? '-translate-x-4 sm:-translate-x-12' : ''} z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all`}
189-
aria-label="Previous page"
176+
disabled={currentIndex === 0}
177+
className={`absolute -left-4 sm:-left-12 top-1/2 -translate-y-1/2 z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all ${currentIndex === 0 ? 'opacity-30 cursor-default' : ''}`}
178+
aria-label="Previous"
190179
>
191180
<FaChevronLeft className="text-sm" />
192181
</button>
193182
<button
194183
type="button"
195184
onClick={() => go(1)}
196-
className={`absolute ${columns === 1 ? 'right-0' : '-right-4 sm:-right-12'} top-1/2 -translate-y-1/2 ${columns === 1 ? 'translate-x-4 sm:translate-x-12' : ''} z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all`}
197-
aria-label="Next page"
185+
disabled={currentIndex >= maxIndex}
186+
className={`absolute -right-4 sm:-right-12 top-1/2 -translate-y-1/2 z-10 p-2.5 rounded-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:border-surface-400 dark:hover:border-surface-500 shadow-sm transition-all ${currentIndex >= maxIndex ? 'opacity-30 cursor-default' : ''}`}
187+
aria-label="Next"
198188
>
199189
<FaChevronRight className="text-sm" />
200190
</button>
201191

202-
{/* Cards Grid */}
203-
<div className={`overflow-hidden rounded-xl ${columns === 1 ? 'min-h-[420px]' : 'min-h-[480px]'}`}>
204-
<AnimatePresence mode="sync" custom={direction}>
205-
<motion.div
206-
key={pageIndex}
207-
custom={direction}
208-
variants={variants}
209-
initial="enter"
210-
animate="center"
211-
exit="exit"
212-
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
213-
className={`grid ${gridClass} gap-6`}
214-
>
215-
{pageProjects.map((project) => (
216-
<article
217-
key={project.id}
218-
onClick={() => onSelect(project)}
219-
className="bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl overflow-hidden hover:shadow-lg dark:hover:border-surface-600 transition-shadow cursor-pointer"
220-
>
221-
<ProjectThumbnail project={project} className="h-44" />
222-
<div className="p-5">
223-
<span className="inline-block text-[10px] font-semibold uppercase tracking-wider text-surface-400 mb-2">
224-
{project.category}
225-
</span>
226-
<h3 className="text-base font-bold text-surface-900 dark:text-white mb-2">
227-
{project.title}
228-
</h3>
229-
<p className="text-sm text-surface-500 dark:text-surface-400 leading-relaxed mb-3 line-clamp-3">
230-
{project.description}
231-
</p>
232-
<div className="flex flex-wrap gap-1.5 mb-3">
233-
{project.technologies.slice(0, 4).map((tech) => (
234-
<span
235-
key={tech}
236-
className="px-2 py-0.5 bg-surface-50 dark:bg-surface-700 text-surface-600 dark:text-surface-300 rounded text-[11px] font-medium border border-surface-100 dark:border-surface-600"
237-
>
238-
{tech}
239-
</span>
240-
))}
241-
{project.technologies.length > 4 && (
242-
<span className="px-2 py-0.5 text-surface-400 text-[11px]">
243-
+{project.technologies.length - 4}
244-
</span>
245-
)}
246-
</div>
247-
<div className="flex items-center gap-3 pt-3 border-t border-surface-100 dark:border-surface-700">
192+
{/* Sliding container */}
193+
<div ref={containerRef} className="overflow-hidden rounded-xl">
194+
<motion.div
195+
animate={{ x: `calc(${offsetPercent}% - ${currentIndex * gapPx}px)` }}
196+
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
197+
className="flex"
198+
style={{ gap: `${gapPx}px` }}
199+
>
200+
{projects.map((project) => (
201+
<article
202+
key={project.id}
203+
onClick={() => onSelect(project)}
204+
className="bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl overflow-hidden hover:shadow-lg dark:hover:border-surface-600 transition-shadow cursor-pointer flex-shrink-0"
205+
style={{ width: `calc(${cardPercent}% - ${gapPx * (columns - 1) / columns}px)` }}
206+
>
207+
<ProjectThumbnail project={project} className="h-44" />
208+
<div className="p-5">
209+
<span className="inline-block text-[10px] font-semibold uppercase tracking-wider text-surface-400 mb-2">
210+
{project.category}
211+
</span>
212+
<h3 className="text-base font-bold text-surface-900 dark:text-white mb-2">
213+
{project.title}
214+
</h3>
215+
<p className="text-sm text-surface-500 dark:text-surface-400 leading-relaxed mb-3 line-clamp-3">
216+
{project.description}
217+
</p>
218+
<div className="flex flex-wrap gap-1.5 mb-3">
219+
{project.technologies.slice(0, 4).map((tech) => (
220+
<span
221+
key={tech}
222+
className="px-2 py-0.5 bg-surface-50 dark:bg-surface-700 text-surface-600 dark:text-surface-300 rounded text-[11px] font-medium border border-surface-100 dark:border-surface-600"
223+
>
224+
{tech}
225+
</span>
226+
))}
227+
{project.technologies.length > 4 && (
228+
<span className="px-2 py-0.5 text-surface-400 text-[11px]">
229+
+{project.technologies.length - 4}
230+
</span>
231+
)}
232+
</div>
233+
<div className="flex items-center gap-3 pt-3 border-t border-surface-100 dark:border-surface-700">
234+
<a
235+
href={project.githubUrl}
236+
target="_blank"
237+
rel="noopener noreferrer"
238+
onClick={(e) => e.stopPropagation()}
239+
className="inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-surface-900 dark:hover:text-white transition-colors"
240+
>
241+
<FaGithub className="text-sm" />
242+
Code
243+
</a>
244+
{project.liveUrl && (
248245
<a
249-
href={project.githubUrl}
246+
href={project.liveUrl}
250247
target="_blank"
251248
rel="noopener noreferrer"
252249
onClick={(e) => e.stopPropagation()}
253-
className="inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-surface-900 dark:hover:text-white transition-colors"
250+
className="inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-primary-600 transition-colors"
254251
>
255-
<FaGithub className="text-sm" />
256-
Code
252+
<FaExternalLinkAlt className="text-[10px]" />
253+
Live Demo
257254
</a>
258-
{project.liveUrl && (
259-
<a
260-
href={project.liveUrl}
261-
target="_blank"
262-
rel="noopener noreferrer"
263-
onClick={(e) => e.stopPropagation()}
264-
className="inline-flex items-center gap-1.5 text-xs font-medium text-surface-500 hover:text-primary-600 transition-colors"
265-
>
266-
<FaExternalLinkAlt className="text-[10px]" />
267-
Live Demo
268-
</a>
269-
)}
270-
</div>
255+
)}
271256
</div>
272-
</article>
273-
))}
274-
</motion.div>
275-
</AnimatePresence>
257+
</div>
258+
</article>
259+
))}
260+
</motion.div>
276261
</div>
277262
</div>
278263

279-
{/* Dot indicators */}
264+
{/* Progress indicator */}
280265
<div className="flex items-center gap-1.5 mt-6">
281-
{Array.from({ length: maxStart + 1 }).map((_, i) => (
266+
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
282267
<button
283268
key={i}
284269
type="button"
285-
onClick={() => {
286-
setDirection(i > pageIndex ? 1 : -1);
287-
setPageIndex(i);
288-
}}
270+
onClick={() => setCurrentIndex(i)}
289271
className={`rounded-full transition-all duration-200 ${
290-
i === pageIndex
272+
i === currentIndex
291273
? 'w-6 h-2 bg-surface-900 dark:bg-white'
292274
: 'w-2 h-2 bg-surface-300 dark:bg-surface-600 hover:bg-surface-400 dark:hover:bg-surface-500'
293275
}`}
@@ -296,7 +278,7 @@ const CarouselView: React.FC<{
296278
))}
297279
</div>
298280
<p className="text-xs text-surface-400 mt-3">
299-
{pageIndex + 1}&ndash;{Math.min(pageIndex + columns, projects.length)} of {projects.length}
281+
{currentIndex + 1}&ndash;{Math.min(currentIndex + columns, projects.length)} of {projects.length}
300282
</p>
301283
</div>
302284
);

0 commit comments

Comments
 (0)