Skip to content

Commit 21306ef

Browse files
committed
fix(landing): smooth featured customer transitions
1 parent 7237f81 commit 21306ef

3 files changed

Lines changed: 85 additions & 35 deletions

File tree

apps/sim/app/(landing)/components/featured-customer/featured-customer-card.tsx

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { ChipTag, cn } from '@sim/emcn'
1+
'use client'
2+
3+
import { useEffect, useRef } from 'react'
4+
import { ChipTag, cn, usePrefersReducedMotion } from '@sim/emcn'
25
import Image from 'next/image'
36
import { LANDING_STAGE_RADIUS } from '@/app/(landing)/components/landing-layout'
47

@@ -35,8 +38,22 @@ interface FeaturedCustomerCardProps {
3538

3639
/** Shared media and editorial caption treatment for each featured-customer slide. */
3740
export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCustomerCardProps) {
41+
const videoRef = useRef<HTMLVideoElement>(null)
42+
const reducedMotion = usePrefersReducedMotion()
3843
const isFilm = story.media.kind === 'video'
3944

45+
useEffect(() => {
46+
const video = videoRef.current
47+
if (!video) return
48+
49+
if (active && !reducedMotion) {
50+
/** Autoplay may be blocked; the poster remains the fallback. */
51+
void video.play().catch(() => {})
52+
} else {
53+
video.pause()
54+
}
55+
}, [active, reducedMotion])
56+
4057
return (
4158
<article
4259
aria-hidden={!active}
@@ -56,31 +73,27 @@ export function FeaturedCustomerCard({ story, active, emphasized }: FeaturedCust
5673
quality={90}
5774
className='object-cover'
5875
/>
59-
{active && (
60-
<video
61-
aria-hidden='true'
62-
autoPlay
63-
loop
64-
muted
65-
playsInline
66-
preload='metadata'
67-
poster={story.media.poster}
68-
src={story.media.src}
69-
tabIndex={-1}
70-
className='pointer-events-none absolute inset-0 size-full object-cover motion-reduce:hidden'
71-
/>
72-
)}
76+
<video
77+
ref={videoRef}
78+
aria-hidden='true'
79+
loop
80+
muted
81+
playsInline
82+
preload='metadata'
83+
poster={story.media.poster}
84+
src={story.media.src}
85+
tabIndex={-1}
86+
className='pointer-events-none absolute inset-0 size-full object-cover motion-reduce:hidden'
87+
/>
7388
<div className='absolute inset-0 bg-[linear-gradient(135deg,rgba(0,0,0,0.68)_0%,rgba(0,0,0,0.3)_36%,rgba(0,0,0,0.08)_70%)]' />
7489
</>
7590
)}
7691

7792
<div
7893
data-customer-story-content='true'
7994
className={cn(
80-
'relative z-10 flex h-full items-start p-12 transition-[opacity,transform] duration-500 ease-out motion-reduce:transform-none motion-reduce:transition-none max-sm:p-6 max-lg:p-8',
81-
emphasized
82-
? cn('translate-y-0 opacity-100', active ? 'delay-150' : 'delay-0')
83-
: 'translate-y-2 opacity-40 delay-0'
95+
'relative z-10 flex h-full items-start p-12 transition-[opacity,translate] duration-600 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:translate-y-0 motion-reduce:transition-none max-sm:p-6 max-lg:p-8',
96+
emphasized ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-40'
8497
)}
8598
>
8699
<div className='max-w-[42rem]'>

apps/sim/app/(landing)/components/featured-customer/featured-customer.test.tsx

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
*/
44
import { act, type ReactNode } from 'react'
55
import { createRoot } from 'react-dom/client'
6-
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const motionPreference = vi.hoisted(() => ({ reduced: false }))
79

810
vi.mock('@sim/emcn', () => ({
911
Button: (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props} />,
1012
ChipTag: ({ children }: { children: ReactNode }) => <span>{children}</span>,
1113
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
14+
usePrefersReducedMotion: () => motionPreference.reduced,
1215
Tooltip: {
1316
Root: ({ children }: { children: ReactNode }) => children,
1417
Trigger: ({ children }: { children: ReactNode }) => children,
@@ -31,8 +34,15 @@ vi.mock('@sim/emcn/icons', () => ({
3134

3235
import { FeaturedCustomer } from '@/app/(landing)/components/featured-customer/featured-customer'
3336

37+
beforeEach(() => {
38+
motionPreference.reduced = false
39+
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
40+
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
41+
})
42+
3443
afterEach(() => {
3544
vi.useRealTimers()
45+
vi.restoreAllMocks()
3646
document.body.replaceChildren()
3747
})
3848

@@ -56,6 +66,10 @@ describe('FeaturedCustomer', () => {
5666
'[data-customer-story-content="true"]'
5767
) as HTMLElement
5868
const carouselRail = host.querySelector('[data-customer-carousel-rail="true"]') as HTMLElement
69+
const video = rivian.querySelector('video') as HTMLVideoElement
70+
video.currentTime = 12
71+
72+
expect(video.play).toHaveBeenCalledOnce()
5973

6074
expect(rivian.querySelector('img[alt="Rivian | Volkswagen Group Technologies"]')).not.toBeNull()
6175
expect(host.querySelector('blockquote')).toBeNull()
@@ -113,9 +127,8 @@ describe('FeaturedCustomer', () => {
113127
expect(expRealty.className).toContain('opacity-100')
114128
expect(expRealtyContent.className).toContain('translate-y-0')
115129
expect(expRealtyContent.className).toContain('opacity-100')
116-
expect(expRealtyContent.className).toContain('delay-150')
117-
expect(carouselRail.className).toContain('xl:pl-24')
118-
expect(carouselRail.className).not.toContain('xl:pr-24')
130+
expect(carouselRail.className).toContain('xl:translate-x-24')
131+
expect(carouselRail.className).toContain('xl:pr-24')
119132
const previousButton = host.querySelector(
120133
'[aria-label="View Rivian customer story"]'
121134
) as HTMLButtonElement
@@ -125,7 +138,9 @@ describe('FeaturedCustomer', () => {
125138
) as HTMLButtonElement
126139
expect(disabledNext.disabled).toBe(true)
127140
expect(expRealty.querySelector('img[alt="eXp Realty"]')).not.toBeNull()
128-
expect(host.querySelector('video')).toBeNull()
141+
expect(host.querySelector('video')).toBe(video)
142+
expect(video.currentTime).toBe(12)
143+
expect(video.pause).toHaveBeenCalledOnce()
129144
expect(
130145
[...expRealty.querySelectorAll('img')].map((image) => image.getAttribute('src'))
131146
).toEqual(['/landing/logos/exp-realty.svg'])
@@ -139,12 +154,38 @@ describe('FeaturedCustomer', () => {
139154

140155
expect(rivian.getAttribute('aria-current')).toBe('true')
141156
expect(expRealty.getAttribute('aria-current')).toBeNull()
157+
expect(rivian.querySelector('video')).toBe(video)
158+
expect(video.currentTime).toBe(12)
159+
expect(video.play).toHaveBeenCalledTimes(2)
142160

143161
act(() => {
144162
root.unmount()
145163
})
146164
})
147165

166+
it('keeps video paused with reduced motion and responds to preference changes', () => {
167+
motionPreference.reduced = true
168+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
169+
const host = document.createElement('div')
170+
document.body.appendChild(host)
171+
const root = createRoot(host)
172+
173+
act(() => root.render(<FeaturedCustomer />))
174+
const video = host.querySelector('video') as HTMLVideoElement
175+
expect(video.play).not.toHaveBeenCalled()
176+
expect(video.pause).toHaveBeenCalledOnce()
177+
178+
motionPreference.reduced = false
179+
act(() => root.render(<FeaturedCustomer />))
180+
expect(video.play).toHaveBeenCalledOnce()
181+
182+
motionPreference.reduced = true
183+
act(() => root.render(<FeaturedCustomer />))
184+
expect(video.pause).toHaveBeenCalledTimes(2)
185+
186+
act(() => root.unmount())
187+
})
188+
148189
it('emphasizes and selects the neighboring customer story', () => {
149190
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
150191
const host = document.createElement('div')

apps/sim/app/(landing)/components/featured-customer/featured-customer.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const CUSTOMER_STORIES: FeaturedCustomerStory[] = [
4343
/**
4444
* Customer-story carousel with one full-emphasis card and a reduced-scale
4545
* adjacent preview. Only the active film card plays video; an inactive film
46-
* keeps its poster visible, and a brand card uses its own wordmark. A previous/next pair on the page
46+
* holds its current frame, and a brand card uses its own wordmark. A previous/next pair on the page
4747
* ground above the film, fixed flush with the first card's right edge, moves
4848
* between stories, with the arrow that has nowhere to go disabled.
4949
*/
@@ -63,11 +63,6 @@ export function FeaturedCustomer() {
6363
className='w-full overflow-hidden'
6464
>
6565
<div className={cn(LANDING_CONTENT_WIDTH, LANDING_GUTTER)}>
66-
{/* The control pair lives on the page ground above the film, outside
67-
the rail whose padding flips to shift the frame, so it keeps one
68-
place: flush with the first card's right edge, whichever story is
69-
active. Both arrows are always present; the direction with no story
70-
is disabled. */}
7166
<div className='mb-4 flex items-center justify-end gap-2 xl:pr-24'>
7267
<FeaturedCustomerNavigationButton
7368
direction='previous'
@@ -90,8 +85,8 @@ export function FeaturedCustomer() {
9085
<div
9186
data-customer-carousel-rail='true'
9287
className={cn(
93-
'transition-[padding] duration-700 ease-out motion-reduce:duration-0',
94-
activeIndex === 0 ? 'xl:pr-24' : 'xl:pl-24'
88+
'transition-[translate] duration-600 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none xl:pr-24',
89+
activeIndex > 0 && 'xl:translate-x-24'
9590
)}
9691
>
9792
<div className='relative isolate aspect-[2/1] w-full max-sm:aspect-[4/5]'>
@@ -112,14 +107,15 @@ export function FeaturedCustomer() {
112107
onFocus={() => !isActive && setPreviewedIndex(index)}
113108
onBlur={() => setPreviewedIndex(null)}
114109
className={cn(
115-
'absolute inset-0 transition-[transform,opacity] duration-700 ease-out motion-reduce:duration-0',
110+
'absolute inset-0 transition-[translate,scale,opacity] duration-600 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
111+
index === 0 ? 'origin-right' : 'origin-left',
116112
isActive && 'z-10 translate-x-0 scale-100 opacity-100',
117113
!isActive &&
118114
isNext &&
119-
'z-20 origin-left translate-x-[calc(100%_+_1.5rem)] scale-[0.92] max-sm:translate-x-[calc(100%_+_0.75rem)] max-sm:scale-[0.96]',
115+
'z-20 translate-x-[calc(100%_+_1.5rem)] scale-[0.92] max-sm:translate-x-[calc(100%_+_0.75rem)] max-sm:scale-[0.96]',
120116
!isActive &&
121117
!isNext &&
122-
'-translate-x-[calc(100%_+_1.5rem)] max-sm:-translate-x-[calc(100%_+_0.75rem)] z-20 origin-right scale-[0.92] max-sm:scale-[0.96]',
118+
'-translate-x-[calc(100%_+_1.5rem)] max-sm:-translate-x-[calc(100%_+_0.75rem)] z-20 scale-[0.92] max-sm:scale-[0.96]',
123119
!isActive && (isPreviewed ? 'opacity-100' : 'opacity-75')
124120
)}
125121
>

0 commit comments

Comments
 (0)