Image Strobe
A React strobe effect that flips full-bleed images at high speed while centred content stays fixed, stopping on the last frame; triggered on entering the viewport, on mount or via ref.play(), with a slow crossfade under reduced motion.
A strobing hero for film festivals, launch events and brand landing pages. Every layer is mounted at once and stacked in the same spot, then hard-cut with opacity, one frame every intervalMs (80ms by default), while the centred children (a wordmark, a tagline) never move. After cycles rounds (or durationMs) it rests on the last frame and calls onEnd. A layer can be an { src, alt } image or any ReactNode — a plain CSS-gradient div, for example.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add image-strobe.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<ImageStrobe/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/image-strobe.en.jsonOr, once registries are configured in components.json, install it as @webberui/image-strobe.
Usage
import * as React from "react";
import { ImageStrobe, type ImageStrobeHandle } from "@/components/ui/image-strobe";
const frames = [
{ src: "/festival/01.jpg" },
{ src: "/festival/02.jpg" },
{ src: "/festival/03.jpg", alt: "Poster key art: a screening hall at night" },
];
export function Hero() {
const ref = React.useRef<ImageStrobeHandle>(null);
return (
<>
<ImageStrobe ref={ref} images={frames} intervalMs={80} cycles={3} scrim>
<h1 className="text-4xl font-semibold text-white">LUMEN REEL 2026</h1>
</ImageStrobe>
<button type="button" onClick={() => ref.current?.play()}>
Replay
</button>
</>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
images | ImageStrobeItem[] | — | Layers: { src, alt } or ReactNode, flipped in order |
intervalMs | number | 80 | Milliseconds per frame (mirrored as the --wb-is-interval variable; the prop wins) |
cycles | number | 3 | How many rounds through images; durationMs takes precedence when set |
durationMs | number | — | Total playback length in milliseconds; overrides cycles |
endIndex | number | last frame | Layer index to rest on when playback ends |
trigger | "mount" | "inView" | "manual" | "inView" | Play on mount, once when half of it enters the viewport, or only via ref.play() |
flash | boolean | false | Overlay a one-frame faint white flash on every cut |
scrim | boolean | false | Add a vignette scrim between the layers and the centred content (override with --wb-is-scrim) |
children | ReactNode | — | Content pinned to the centre that never changes with the flips |
aspect | string | "16/9" | Container aspect ratio (CSS aspect-ratio) |
onEnd | () => void | — | Called when playback ends and rests on the final frame |
ref | Ref<ImageStrobeHandle> | — | Imperative controls; play() (re)starts from the first frame |
className | string | — | Passed through to the outermost container |
ImageStrobeImage
| Field | Type | Default | Description |
|---|---|---|---|
src | string | — | Image URL |
alt | string | — | Alt text; only the final resting frame is exposed to screen readers (sr-only) |
ImageStrobeItem is ImageStrobeImage \| ReactNode.
ImageStrobeHandle
| Field | Type | Default | Description |
|---|---|---|---|
play | () => void | — | (Re)start playback from the first frame |
How it works
- Every layer is mounted at once and stacked in the same spot (
absolute inset-0,object-cover), then hard-cut between opacity 0 and 1 with no transition, so nothing is re-decoded on each cut. - All images are preloaded with
img.decode()before playback starts; until then the first frame is shown. - Timing accumulates
requestAnimationFrametime instead of usingsetInterval: nothing piles up while the tab is in the background, and there is no burst of flips when it comes back. - Length:
durationMswins when set, otherwise it runscyclesrounds; it then rests onendIndex(the last frame by default) and callsonEnd. - Swap the vignette with the
--wb-is-scrimCSS variable, and tune the reduced-motion crossfade with--wb-is-fade(1200ms by default). - Photosensitivity warning: 80ms is roughly 12.5 flashes per second. Avoid strobing large, high-contrast areas (black/white alternation, saturated reds), prefer images of similar brightness and keep the total run short; the component does not adjust brightness differences between neighbouring frames for you.
Accessibility
- The layer container is
aria-hidden="true": the strobe is decorative and is not announced frame by frame. - If the final resting image is meaningful, pass
altand it is exposed as sr-only text; the centredchildrenstay readable and interactive as usual. trigger="inView"fires only once, so scrolling back and forth does not flash again; replays are left to an explicit user action (such as a button callingref.play()).- When the user's system has reduce motion turned on, there is no strobe: it plays one slow round with a 1.2-second crossfade per frame, then rests on the last frame as usual.
Cover Wall
A React grid wall of covers that fills in cell by cell from the centre outward, with a rolling counter and copy overlaid in the middle; hovering pushes a cell forward while neighbours recede.
Aurora Background
A flowing aurora background — large blurred color blobs drift slowly via transform, with optional grain overlay and mouse tracking.