Ken Burns Pan
A still picture slowly zooms and drifts inside its frame, the documentary trick that makes a photo feel like footage.
Make a CSS Ken Burns effect for a React hero image. Put the <img> (object-fit: cover) inside a frame with overflow hidden and a fixed aspect ratio. Animate the image with one keyframe that alternates: from scale(1) translate(0, 0) to scale(1.18) translate(-4%, -3%), over 14s, ease-in-out, infinite alternate, so it never jumps when the loop restarts. Set transform-origin near the subject (for example 60% 40%). Overlay a bottom gradient from transparent to 60% black and a caption "Langkawi at dusk" in the corner. Add will-change: transform. Pause the animation with animation-play-state: paused when the frame is off screen (IntersectionObserver) and under prefers-reduced-motion show the still image.
Source
'use client'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
export default function KenBurns({ labels }: { labels: Record<string, string> }) {
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(([e]) => el.toggleAttribute('data-paused', !e.isIntersecting))
io.observe(el)
return () => io.disconnect()
}, [])
return (
<figure ref={ref} className={s.frame}>
<div className={s.scene} role="img" aria-label={labels.caption} />
<figcaption className={s.caption}>{labels.caption}</figcaption>
</figure>
)
}
.frame {
--sky-top: oklch(42% 0.09 300);
--sky-low: oklch(70% 0.14 45);
--sun: oklch(88% 0.11 75);
--sea: oklch(36% 0.07 250);
--island: oklch(22% 0.03 280);
--shade: oklch(0% 0 0 / 0.6);
position: relative;
width: min(300px, 100%);
aspect-ratio: 16 / 10;
max-width: 100%;
margin: 0;
overflow: hidden;
border-radius: 14px;
}
.scene {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 22% 14% at 30% 62%, var(--island) 97%, transparent 100%),
radial-gradient(ellipse 34% 18% at 78% 64%, var(--island) 97%, transparent 100%),
linear-gradient(180deg, transparent 62%, var(--sea) 62%),
radial-gradient(circle at 58% 56%, var(--sun) 0 10%, transparent 10.5%),
linear-gradient(180deg, var(--sky-top), var(--sky-low) 62%);
transform-origin: 60% 40%;
will-change: transform;
animation: drift 14s ease-in-out infinite alternate;
}
.frame[data-paused] .scene { animation-play-state: paused; }
.frame::after {
content: '';
position: absolute;
inset: 50% 0 0;
background: linear-gradient(transparent, var(--shade));
}
.caption {
position: absolute;
left: 12px;
bottom: 10px;
z-index: 1;
font: 500 var(--text-sm) / 1.2 var(--font-body);
color: var(--stage-ink);
}
@keyframes drift {
from { transform: scale(1) translate(0, 0); }
to { transform: scale(1.18) translate(-4%, -3%); }
}
@media (prefers-reduced-motion: reduce) {
.scene { animation: none; }
}