1+ 'use client'
2+
3+ import { type PointerEvent , useEffect , useRef } from 'react'
14import { cn } from '@sim/emcn'
25import Image from 'next/image'
36import { 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 */
1215const COPIES = [ 0 , 1 ] as const
13- const TRACK_MOTION = styles . track
1416const EDGE_FADE =
1517 '[mask-image:linear-gradient(to_right,transparent,black_14%,black_86%,transparent)]'
1618const 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 */
2545export 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