Skip to content

Commit a372f33

Browse files
committed
fix(landing): enable drag and trackpad scrolling in customer carousel
Scrub the existing logo loop with pointer drags, horizontal trackpad gestures, and arrow keys while preserving autoplay position and native trackpad momentum. Keep reduced-motion support and normal vertical scrolling and pinch-to-zoom behavior.
1 parent 3a4dba5 commit a372f33

2 files changed

Lines changed: 108 additions & 10 deletions

File tree

apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee/nav-menu-logo-marquee.module.css

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
@media (prefers-reduced-motion: no-preference) {
1+
.track {
2+
animation: marquee 16s linear infinite;
3+
}
4+
5+
.viewport:hover .track,
6+
.viewport:focus-visible .track,
7+
.viewport[data-dragging] .track {
8+
animation-play-state: paused;
9+
}
10+
11+
@media (prefers-reduced-motion: reduce) {
212
.track {
3-
animation: marquee 16s linear infinite;
4-
}
5-
.track:hover {
613
animation-play-state: paused;
714
}
815
}

apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee/nav-menu-logo-marquee.tsx

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
'use client'
2+
3+
import { type PointerEvent, useEffect, useRef } from 'react'
14
import { cn } from '@sim/emcn'
25
import Image from 'next/image'
36
import { LOGOS, MUTED_MARK } from '@/app/(landing)/components/logos'
@@ -10,25 +13,113 @@ import styles from '@/app/(landing)/components/navbar/components/nav-menu-chip/c
1013
* even: a copy is about 800px, so 16s is roughly 50px/s.
1114
*/
1215
const COPIES = [0, 1] as const
13-
const TRACK_MOTION = styles.track
1416
const EDGE_FADE =
1517
'[mask-image:linear-gradient(to_right,transparent,black_14%,black_86%,transparent)]'
1618
const LABEL = 'Companies building and governing AI agents with Sim'
19+
const KEYBOARD_STEP = 120
20+
21+
interface MarqueeDrag {
22+
pointerId: number
23+
lastX: number
24+
}
25+
26+
function moveTrack(track: HTMLDivElement | null, distance: number) {
27+
const animation = track?.getAnimations()[0]
28+
const duration = animation?.effect?.getComputedTiming().duration
29+
const width = (track?.offsetWidth ?? 0) / COPIES.length
30+
if (!animation || typeof duration !== 'number' || !duration || !width) return
31+
32+
const time = Number(animation.currentTime ?? 0) - (distance / width) * duration
33+
animation.currentTime = ((time % duration) + duration) % duration
34+
}
1735

1836
/**
1937
* The customer wordmarks sliding in a muted band under a floating menu's
2038
* blocs - the homepage's shared logo set at its optical sizes, the way the
2139
* platform pages show them. The second copy of the row exists only for the
2240
* seamless loop and is hidden from assistive technology; a reduced-motion
23-
* preference leaves the row still.
41+
* preference leaves the row still. Dragging, trackpad scrolling, and arrow
42+
* keys scrub the same animation timeline, preserving its position when
43+
* autoplay resumes.
2444
*/
2545
export function NavMenuLogoMarquee() {
46+
const viewportRef = useRef<HTMLDivElement>(null)
47+
const trackRef = useRef<HTMLDivElement>(null)
48+
const dragRef = useRef<MarqueeDrag | null>(null)
49+
50+
useEffect(() => {
51+
const viewport = viewportRef.current
52+
if (!viewport) return
53+
54+
const onWheel = (event: WheelEvent) => {
55+
if (event.ctrlKey || dragRef.current) return
56+
const delta = event.deltaX || (event.shiftKey ? event.deltaY : 0)
57+
if (!delta || (!event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX))) return
58+
59+
const unit =
60+
event.deltaMode === WheelEvent.DOM_DELTA_LINE
61+
? 16
62+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
63+
? viewport.clientWidth
64+
: 1
65+
if (event.cancelable) event.preventDefault()
66+
moveTrack(trackRef.current, -delta * unit)
67+
}
68+
69+
/** Native wheel deltas include trackpad momentum; non-passive handling keeps horizontal swipes in the carousel. */
70+
viewport.addEventListener('wheel', onWheel, { passive: false })
71+
return () => viewport.removeEventListener('wheel', onWheel)
72+
}, [])
73+
74+
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
75+
if (dragRef.current?.pointerId !== event.pointerId) return
76+
dragRef.current = null
77+
delete event.currentTarget.dataset.dragging
78+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
79+
event.currentTarget.releasePointerCapture(event.pointerId)
80+
}
81+
}
82+
2683
return (
27-
<div className={cn('overflow-hidden py-2', EDGE_FADE)}>
84+
<div
85+
ref={viewportRef}
86+
role='region'
87+
aria-label='Customer logos'
88+
aria-description='Drag, scroll horizontally, or use the left and right arrow keys to browse customer logos.'
89+
/** biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users can scrub this carousel with the arrow keys. */
90+
tabIndex={0}
91+
className={cn(
92+
'cursor-grab touch-pan-y select-none overflow-hidden py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--border)] focus-visible:outline-offset-[-2px] data-[dragging]:cursor-grabbing',
93+
styles.viewport,
94+
EDGE_FADE
95+
)}
96+
onPointerDown={(event) => {
97+
if (!event.isPrimary || event.button !== 0) return
98+
event.preventDefault()
99+
dragRef.current = { pointerId: event.pointerId, lastX: event.clientX }
100+
event.currentTarget.dataset.dragging = ''
101+
event.currentTarget.setPointerCapture(event.pointerId)
102+
}}
103+
onPointerMove={(event) => {
104+
const drag = dragRef.current
105+
if (!drag || drag.pointerId !== event.pointerId) return
106+
moveTrack(trackRef.current, event.clientX - drag.lastX)
107+
drag.lastX = event.clientX
108+
}}
109+
onPointerUp={endDrag}
110+
onPointerCancel={endDrag}
111+
onLostPointerCapture={endDrag}
112+
onKeyDown={(event) => {
113+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
114+
event.preventDefault()
115+
moveTrack(trackRef.current, event.key === 'ArrowLeft' ? KEYBOARD_STEP : -KEYBOARD_STEP)
116+
}}
117+
>
28118
<div
119+
ref={trackRef}
29120
className={cn(
30121
'flex w-max items-center will-change-transform [backface-visibility:hidden]',
31-
TRACK_MOTION
122+
styles.track
32123
)}
33124
>
34125
{COPIES.map((copy) => {
@@ -47,9 +138,9 @@ export function NavMenuLogoMarquee() {
47138
alt={decorative ? '' : logo.name}
48139
height={logo.height}
49140
width={Math.round(logo.height * logo.aspect)}
50-
/* The panel is hidden until it opens; lazy marks would arrive late on the first open. */
51141
loading='eager'
52-
className={MUTED_MARK}
142+
draggable={false}
143+
className={cn('pointer-events-none', MUTED_MARK)}
53144
/>
54145
</li>
55146
))}

0 commit comments

Comments
 (0)