fix(home): slow sponsor ticker

This commit is contained in:
syntaxbullet
2026-07-31 17:04:27 +02:00
parent 95037e9908
commit adda9ddbd0
3 changed files with 210 additions and 35 deletions

View File

@@ -1,52 +1,151 @@
import React from "react";
import React, { useState } from 'react'
interface PartnerLogo {
id: string;
label: React.ReactNode;
id: string
label: React.ReactNode
}
interface PartnerTickerProps {
logos: PartnerLogo[];
ariaLabel: string
logos: PartnerLogo[]
}
export function PartnerTicker({ logos }: PartnerTickerProps) {
// Duplicate logos for seamless infinite loop
const track = [...logos, ...logos];
export const PARTNER_TICKER_ITEM_WIDTH_PX = 260
export const PARTNER_TICKER_SPEED_PX_PER_SECOND = 48
export const PARTNER_TICKER_MIN_DURATION_SECONDS = 24
export function partnerTickerDurationSeconds(logoCount: number) {
if (logoCount <= 0) return 0
const loopDistance = logoCount * PARTNER_TICKER_ITEM_WIDTH_PX
return Math.max(
PARTNER_TICKER_MIN_DURATION_SECONDS,
loopDistance / PARTNER_TICKER_SPEED_PX_PER_SECOND,
)
}
export function PartnerTicker({ ariaLabel, logos }: PartnerTickerProps) {
const [hasFocusWithin, setHasFocusWithin] = useState(false)
const [isHovered, setIsHovered] = useState(false)
if (logos.length === 0) return null
const durationSeconds = partnerTickerDurationSeconds(logos.length)
const isPaused = hasFocusWithin || isHovered
const renderLogoCopy = (copy: number) => (
<div
aria-hidden={copy === 1 ? 'true' : undefined}
className="partner-ticker-copy"
data-testid="partner-ticker-copy"
key={copy}
style={{ display: 'flex' }}
>
{logos.map((logo) => (
<div
key={`${copy}-${logo.id}`}
style={{
alignItems: 'center',
color: '#fff',
display: 'flex',
flexShrink: 0,
fontFamily: '"IBM Plex Sans", sans-serif',
justifyContent: 'center',
padding: '0 24px',
textAlign: 'center',
whiteSpace: 'nowrap',
width: PARTNER_TICKER_ITEM_WIDTH_PX,
}}
>
{logo.label}
</div>
))}
</div>
)
return (
<div style={{ overflow: 'hidden', flex: 1, minWidth: 0, position: 'relative' }}>
{/* fade edges */}
<div style={{ position: 'absolute', inset: 0, left: 0, width: 32, background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', inset: 0, right: 0, left: 'auto', width: 32, background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} />
<div
aria-label={ariaLabel}
className="partner-ticker-viewport"
data-duration-seconds={durationSeconds}
data-testid="partner-ticker"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setHasFocusWithin(false)
}
}}
onFocus={() => setHasFocusWithin(true)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="region"
style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative' }}
tabIndex={0}
>
<div
style={{
background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)',
inset: 0,
left: 0,
pointerEvents: 'none',
position: 'absolute',
width: 32,
zIndex: 1,
}}
/>
<div
style={{
background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)',
inset: 0,
left: 'auto',
pointerEvents: 'none',
position: 'absolute',
right: 0,
width: 32,
zIndex: 1,
}}
/>
<div style={{ display: 'flex', animation: 'marquee 22s linear infinite', width: 'max-content' }}>
{track.map((logo, i) => (
<div
key={`${logo.id}-${i}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 24px',
color: '#fff',
fontFamily: '"IBM Plex Sans", sans-serif',
whiteSpace: 'nowrap',
width: 260,
flexShrink: 0,
textAlign: 'center',
}}
>
{logo.label}
</div>
))}
<div
className="partner-ticker-track"
data-testid="partner-ticker-track"
style={{
animation: `partner-marquee ${durationSeconds}s linear infinite`,
animationPlayState: isPaused ? 'paused' : 'running',
display: 'flex',
width: 'max-content',
}}
>
{renderLogoCopy(0)}
{renderLogoCopy(1)}
</div>
<style>{`
@keyframes marquee {
@keyframes partner-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
to { transform: translateX(-50%); }
}
.partner-ticker-viewport:hover .partner-ticker-track,
.partner-ticker-viewport:focus-within .partner-ticker-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.partner-ticker-viewport {
overflow-x: auto !important;
overscroll-behavior-x: contain;
}
.partner-ticker-track {
animation: none !important;
transform: none !important;
}
.partner-ticker-copy[aria-hidden="true"] {
display: none !important;
}
}
`}</style>
</div>
);
)
}