Back to blocks

Ripple Scene Gallery

An immersive scene selector whose photo lifts into a travelling corrugated wave, swapping to the next scene at the crest before settling flat.

marketingripple-wave

Terrain journal

Scene 01of 04

Desert passage · Late afternoon

Distance sharpens the way home.

A study in open roads, mineral light, and the few landmarks that turn exposed terrain into a familiar route.

"use client";

import * as React from "react";

import { RailList, type RailListItem } from "@/components/ui/rail-list";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";

import "./ripple-scene.css";

export type RippleSceneImage = {
  src: string;
  alt: string;
};

export type RippleSceneItem = {
  /** Stable value used by controlled state and callbacks. */
  value: string;
  /** Short label rendered in the horizontal selector. */
  label: string;
  /** Main scene heading. */
  title: string;
  /** Optional supporting copy below the heading. */
  description?: string;
  /** Optional context shown above the heading. */
  overline?: string;
  image: RippleSceneImage;
  imagePosition?: React.CSSProperties["objectPosition"];
};

export interface RippleSceneProps
  extends Omit<React.ComponentProps<"section">, "onChange"> {
  items: readonly RippleSceneItem[];
  /** Controlled selected scene value. */
  value?: string;
  /** Initial selected scene value for uncontrolled usage. */
  defaultValue?: string;
  /** Called when a scene is selected. */
  onValueChange?: (value: string, item: RippleSceneItem) => void;
  /** Accessible label for the scene selector. */
  selectorLabel?: string;
  /** Small persistent label in the stage header. */
  stageLabel?: string;
  /** Message displayed when no scenes are provided. */
  emptyLabel?: string;
}

/** Number of vertical strips the wave is built from. */
const STRIP_COUNT = 12;
/** Per-strip delay that makes the wave travel across the scene. */
const STRIP_STAGGER_MS = 26;
/**
 * animationName of a strip's ripple. When the last strip in the wave
 * finishes, the new scene is promoted to the static base layer. Must match
 * the CSS keyframes name.
 */
const RIPPLE_SETTLE_ANIMATION = "ripple-scene-strip";
/**
 * Safety net for when animationend never arrives (hidden tabs pause CSS
 * animations; user styles may disable them). Must exceed the full wave:
 * strip duration + last strip's delay.
 */
const SETTLE_FALLBACK_MS = 1600;

type MediaStage = {
  /** Scene rendered as the static, untransformed base layer. */
  base: string;
  /** Scene currently rippling in above the base, if any. */
  incoming: string | null;
};

export function RippleScene({
  items,
  value,
  defaultValue,
  onValueChange,
  selectorLabel = "Choose a scene",
  stageLabel = "Selected stories",
  emptyLabel = "No scenes available",
  className,
  ...props
}: RippleSceneProps) {
  const reactId = React.useId();
  const panelId = `${reactId}-panel`;
  const shouldReduceMotion = useReducedMotion();
  const [internalValue, setInternalValue] = React.useState(
    defaultValue ?? items[0]?.value ?? "",
  );
  const controlled = value !== undefined;
  const selectedValue = controlled ? value : internalValue;
  const selectedIndex = Math.max(
    0,
    items.findIndex((item) => item.value === selectedValue),
  );
  const selectedItem = items[selectedIndex];

  // The previous scene stays put as a static base layer while the next scene
  // ripples in above it, so at most one scene is ever animating.
  const [media, setMedia] = React.useState<MediaStage>({
    base: selectedValue,
    incoming: null,
  });
  const shownValue = media.incoming ?? media.base;
  const selectedSrc = selectedItem?.image.src;

  React.useEffect(() => {
    if (!selectedSrc || shownValue === selectedValue) return;

    let cancelled = false;

    // Decode off-screen first so the wave never competes with image decoding.
    const commit = () => {
      if (cancelled) return;

      setMedia((previous) =>
        shouldReduceMotion
          ? { base: selectedValue, incoming: null }
          : { base: previous.incoming ?? previous.base, incoming: selectedValue },
      );
    };

    const image = new window.Image();
    image.src = selectedSrc;

    if (typeof image.decode === "function") {
      image.decode().then(commit, commit);
    } else {
      commit();
    }

    return () => {
      cancelled = true;
    };
  }, [selectedSrc, selectedValue, shownValue, shouldReduceMotion]);

  React.useEffect(() => {
    if (!media.incoming) return;

    const staged = media.incoming;
    const timer = window.setTimeout(() => {
      setMedia((previous) =>
        previous.incoming === staged
          ? { base: staged, incoming: null }
          : previous,
      );
    }, SETTLE_FALLBACK_MS);

    return () => {
      window.clearTimeout(timer);
    };
  }, [media.incoming]);

  const handleRippleSettle = React.useCallback(
    (event: React.AnimationEvent<HTMLDivElement>) => {
      if (event.animationName !== RIPPLE_SETTLE_ANIMATION) return;

      setMedia((previous) =>
        previous.incoming
          ? { base: previous.incoming, incoming: null }
          : previous,
      );
    },
    [],
  );

  const selectValue = React.useCallback(
    (nextValue: string) => {
      const nextItem = items.find((item) => item.value === nextValue);

      if (!nextItem) return;

      if (!controlled) {
        setInternalValue(nextItem.value);
      }

      if (nextItem.value !== selectedItem?.value) {
        onValueChange?.(nextItem.value, nextItem);
      }
    },
    [controlled, items, onValueChange, selectedItem?.value],
  );

  const preloadScene = React.useCallback(
    (railItem: RailListItem) => {
      if (typeof window === "undefined") return;

      const src = items.find((item) => item.value === railItem.value)?.image
        .src;

      if (!src) return;

      const image = new window.Image();
      image.src = src;
      image.decode?.().catch(() => {});
    },
    [items],
  );

  const selectorItems = React.useMemo<RailListItem[]>(
    () =>
      items.map((item, index) => ({
        value: item.value,
        label: item.label,
        id: `${reactId}-tab-${index}`,
        ariaControls: panelId,
      })),
    [items, panelId, reactId],
  );

  if (!selectedItem) {
    return (
      <section
        data-slot="ripple-scene"
        className={cn("ripple-scene ripple-scene--empty", className)}
        {...props}
      >
        <p>{emptyLabel}</p>
      </section>
    );
  }

  const activeTabId = `${reactId}-tab-${selectedIndex}`;
  const baseItem =
    items.find((item) => item.value === media.base) ?? selectedItem;
  const incomingItem = media.incoming
    ? items.find((item) => item.value === media.incoming)
    : undefined;

  // The wave travels toward the newly selected scene: forward selections
  // sweep left-to-right, backward selections sweep right-to-left.
  const baseIndex = items.findIndex((item) => item.value === media.base);
  const incomingIndex = incomingItem
    ? items.findIndex((item) => item.value === incomingItem.value)
    : -1;
  const forward = incomingIndex >= baseIndex;
  const settleStrip = forward ? STRIP_COUNT - 1 : 0;
  const stripWidth = 100 / STRIP_COUNT;

  return (
    <section
      data-slot="ripple-scene"
      data-motion={shouldReduceMotion ? "reduced" : "full"}
      className={cn("ripple-scene", className)}
      {...props}
    >
      <div
        className="ripple-scene__media"
        role="img"
        aria-label={selectedItem.image.alt}
      >
        <div className="ripple-scene__scene ripple-scene__scene--base">
          {/* Registry blocks stay framework-neutral, so consumers can choose their own image loader. */}
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            alt=""
            src={baseItem.image.src}
            draggable={false}
            className="ripple-scene__image"
            style={{ objectPosition: baseItem.imagePosition }}
          />
        </div>
        {incomingItem ? (
          <div
            key={incomingItem.value}
            aria-hidden="true"
            className="ripple-scene__scene ripple-scene__scene--ripple"
          >
            {Array.from({ length: STRIP_COUNT }, (_, index) => {
              const order = forward ? index : STRIP_COUNT - 1 - index;
              // Strips overlap by a hair so no hairline shows once settled.
              const left = Math.max(0, index * stripWidth - 0.06);
              const right = Math.max(0, 100 - (index + 1) * stripWidth - 0.06);

              return (
                <div
                  key={index}
                  className="ripple-scene__strip"
                  data-settle={index === settleStrip ? "true" : undefined}
                  onAnimationEnd={
                    index === settleStrip ? handleRippleSettle : undefined
                  }
                  style={
                    {
                      clipPath: `inset(0 ${right}% 0 ${left}%)`,
                      transformOrigin: `${(index + 0.5) * stripWidth}% 50%`,
                      "--ripple-scene-delay": `${order * STRIP_STAGGER_MS}ms`,
                    } as React.CSSProperties
                  }
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    alt=""
                    src={incomingItem.image.src}
                    draggable={false}
                    className="ripple-scene__image"
                    style={{ objectPosition: incomingItem.imagePosition }}
                  />
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    alt=""
                    src={baseItem.image.src}
                    draggable={false}
                    className="ripple-scene__image ripple-scene__face--out"
                    style={{ objectPosition: baseItem.imagePosition }}
                  />
                </div>
              );
            })}
          </div>
        ) : null}
      </div>

      <div aria-hidden="true" className="ripple-scene__scrim" />

      <div className="ripple-scene__chrome">
        <header className="ripple-scene__header">
          <p>{stageLabel}</p>
          <p aria-live="polite" aria-atomic="true">
            <span className="sr-only">Scene </span>
            {String(selectedIndex + 1).padStart(2, "0")}
            <span aria-hidden="true"> / </span>
            <span className="sr-only">of </span>
            {String(items.length).padStart(2, "0")}
          </p>
        </header>

        <div
          id={panelId}
          role="tabpanel"
          aria-labelledby={activeTabId}
          className="ripple-scene__content"
        >
          <div key={selectedItem.value} className="ripple-scene__content-inner">
            {selectedItem.overline ? (
              <p className="ripple-scene__overline">{selectedItem.overline}</p>
            ) : null}
            <h2>{selectedItem.title}</h2>
            {selectedItem.description ? (
              <p className="ripple-scene__description">
                {selectedItem.description}
              </p>
            ) : null}
          </div>
        </div>

        <RailList
          items={selectorItems}
          value={selectedValue}
          onValueChange={selectValue}
          onItemPointerEnter={preloadScene}
          onItemFocus={preloadScene}
          aria-label={selectorLabel}
          edge="top"
          className="ripple-scene__selector"
          itemClassName="ripple-scene__tab"
          indicatorClassName="ripple-scene__tab-indicator"
        />
      </div>
    </section>
  );
}

Installation