Media

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.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

80
3
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
imagesImageStrobeItem[]—Layers: { src, alt } or ReactNode, flipped in order
intervalMsnumber80Milliseconds per frame (mirrored as the --wb-is-interval variable; the prop wins)
cyclesnumber3How many rounds through images; durationMs takes precedence when set
durationMsnumber—Total playback length in milliseconds; overrides cycles
endIndexnumberlast frameLayer 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()
flashbooleanfalseOverlay a one-frame faint white flash on every cut
scrimbooleanfalseAdd a vignette scrim between the layers and the centred content (override with --wb-is-scrim)
childrenReactNode—Content pinned to the centre that never changes with the flips
aspectstring"16/9"Container aspect ratio (CSS aspect-ratio)
onEnd() => void—Called when playback ends and rests on the final frame
refRef<ImageStrobeHandle>—Imperative controls; play() (re)starts from the first frame
classNamestring—Passed through to the outermost container

ImageStrobeImage

FieldTypeDefaultDescription
srcstring—Image URL
altstring—Alt text; only the final resting frame is exposed to screen readers (sr-only)

ImageStrobeItem is ImageStrobeImage \| ReactNode.

ImageStrobeHandle

FieldTypeDefaultDescription
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 requestAnimationFrame time instead of using setInterval: nothing piles up while the tab is in the background, and there is no burst of flips when it comes back.
  • Length: durationMs wins when set, otherwise it runs cycles rounds; it then rests on endIndex (the last frame by default) and calls onEnd.
  • Swap the vignette with the --wb-is-scrim CSS 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 alt and it is exposed as sr-only text; the centred children stay 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 calling ref.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.