feat(home): add a calm and pleasant hero loop - #1074
Conversation
Swaps the still palm photo on the homepage hero for a 12s video loop of the same shot, with a very gentle push-in (~3% over the full cycle). The clip is built as a ping-pong (forward, then reverse) so the wrap is seamless rather than snapping back to frame 0. The poster is frame 0 of that loop, so the still and the first video frame are the same picture and nothing pops when playback starts. If no source can be decoded the poster simply stays up, which is exactly today's behaviour. Encoding notes: the sky is one long gradient and bands badly in 8-bit, so both files are 10-bit. VP9 profile 2 (959 KB) covers Chrome, Firefox and Edge; HEVC Main10 (897 KB) covers Safari with hardware decode on Apple silicon. An 8-bit H.264 file needed ~2.7 MB for comparable smoothness. Codec strings are spelled out in `type` so Safari skips the 10-bit VP9 instead of selecting it and failing to decode. `prefers-reduced-motion: reduce` holds the loop on its first frame, matching how PixelSpinner handles the same preference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe home page hero changes from a responsive image to a looping video with WebM and MP4 sources, a poster frame, and reduced-motion handling. A new WebP image asset is added for the hero media. ChangesHome page hero video
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/routes/index.tsx`:
- Around line 67-77: Update the hero video logic around heroVideoRef to use the
existing usePrefersReducedMotion hook instead of a one-time matchMedia effect.
Keep autoplay disabled while the preference is unknown and whenever reduced
motion is enabled, and update the video playback behavior when the preference
changes so reduced-motion users do not start downloading or seeing motion
prematurely.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 74d72ce4-d1bb-4d17-a70f-ac0f957d31e4
⛔ Files ignored due to path filters (2)
public/images/hero-palm-gradient-loop.mp4is excluded by!**/*.mp4public/images/hero-palm-gradient-loop.webmis excluded by!**/*.webm
📒 Files selected for processing (2)
public/images/hero-palm-gradient-loop.webpsrc/routes/index.tsx
| const heroVideoRef = React.useRef<HTMLVideoElement>(null) | ||
|
|
||
| // Autoplay is declarative so the loop starts without waiting for hydration; | ||
| // this only walks it back for users who asked for less motion. | ||
| React.useEffect(() => { | ||
| if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) return | ||
| const video = heroVideoRef.current | ||
| if (!video) return | ||
| video.pause() | ||
| video.currentTime = 0 | ||
| }, []) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Gate autoplay on the reduced-motion preference.
The effect runs only once, after the initial autoPlay/preload="auto" markup has already been processed, so reduced-motion users can briefly see motion and still download the video. Preference changes after mount are also ignored. Use the existing usePrefersReducedMotion hook, keep autoplay disabled while its value is unknown, and react to changes.
Suggested direction
+import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion'
...
+ const prefersReducedMotion = usePrefersReducedMotion()
const heroVideoRef = React.useRef<HTMLVideoElement>(null)
React.useEffect(() => {
- if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
+ if (prefersReducedMotion === null) return
const video = heroVideoRef.current
if (!video) return
- video.pause()
- video.currentTime = 0
- }, [])
+ if (prefersReducedMotion) {
+ video.pause()
+ video.currentTime = 0
+ } else {
+ void video.play().catch(() => {})
+ }
+ }, [prefersReducedMotion])
...
- autoPlay
+ autoPlay={prefersReducedMotion === false}
...
- preload="auto"
+ preload={prefersReducedMotion === false ? 'auto' : 'none'}Also applies to: 104-112
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/index.tsx` around lines 67 - 77, Update the hero video logic
around heroVideoRef to use the existing usePrefersReducedMotion hook instead of
a one-time matchMedia effect. Keep autoplay disabled while the preference is
unknown and whenever reduced motion is enabled, and update the video playback
behavior when the preference changes so reduced-motion users do not start
downloading or seeing motion prematurely.
feat(home): add a calm and pleasant hero loop
What
The homepage hero palm now breathes. Same shot, same framing, but as a 12 second
video loop with a very gentle push-in — roughly 3% scale over the full cycle, so
it reads as "alive" rather than "animated".
Because why not.
How it loops
The source is a one-directional zoom, so
loopon its own would visibly snapback to frame 0. The shipped clip is a ping-pong instead — forward
0 → 144,then reverse
143 → 1— which makes the wrap seamless in both directions.Measured as SSIM between the last and first frame:
Poster and fallback
posteris frame 0 of the loop itself, not the existinghero-palm-gradient-1600.webp. The two crops differ slightly, and using the oldstill would cause a visible jump the moment playback starts.
If no
<source>can be decoded, the<video>just keeps showing the poster —which is exactly the current homepage. There is no worse-than-today state.
Encoding
The sky is a single continuous gradient, which bands badly under 8-bit
compression. Both files are therefore 10-bit:
hero-palm-gradient-loop.webmhero-palm-gradient-loop.mp4hero-palm-gradient-loop.webp8-bit H.264 needed roughly 2.7 MB to reach comparable gradient smoothness, so
10-bit is both the better-looking and the smaller option here. Audio and the
cover-art stream are stripped.
The codec strings are written out explicitly in
type:Without them Safari reports generic
video/webmas playable, selects the WebM,and then fails on the 10-bit VP9. With them, source selection resolves correctly
on the first try. Verified in Chromium via
canPlayTypeand actual playback.Reduced motion
prefers-reduced-motion: reducepauses the loop on its first frame, so thoseusers get the still hero. This mirrors how
PixelSpinneralready handles thesame preference.
autoplaystays declarative so the loop does not wait onhydration; the effect only walks it back.
disableRemotePlaybackkeeps Safari from offering an AirPlay overlay on what isa decorative background.
Assets kept
Nothing is deleted.
hero-palm-gradient-960.webpis still used bysrc/routes/ds.stats.tsxand-1600.webpbyscripts/generate-brand-assets.mjsfor the OG image.Known limitation
The source clip is 1168×768, below the 1600/2400 ladder the site ships for the
still.
object-covercrops about 25% of the height in the ~2:1 hero card andscales the remainder by ~1.33×, which absorbs most of it, but on a high-density
display it is softer than the current photo. Happy to re-encode from a
higher-resolution source if that matters for merge.
Summary by CodeRabbit
New Features
Accessibility