shadimage
ericts
blockripple-scenesource: ok · refreshed

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.

npx shadcn add "https://ui.ericts.com/r/ripple-scene.json"
free

Where it goes

gallery

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

https://ui.ericts.com/r/rail-list.jsonhttps://ui.ericts.com/r/use-reduced-motion.json

Files2 · source cached 10/8/2026, 8:51:09 AM

registry/base/blocks/ripple-scene.tsx
"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>
  );
}
registry/base/blocks/ripple-scene.css
/* Defaults are layered for customization. Normal unlayered styles and
   utilities in a later layer can override them. */
@layer components {
  .ripple-scene {
    --ripple-scene-duration: 520ms;
    --ripple-scene-content-duration: 420ms;
    --ripple-scene-ease: cubic-bezier(0.16, 1, 0.3, 1);

    container-type: inline-size;
    position: relative;
    isolation: isolate;
    min-height: 32rem;
    overflow: hidden;
    background: oklch(0.12 0 0);
    color: white;
  }

  .ripple-scene--empty {
    display: grid;
    min-height: 16rem;
    place-items: center;
    background: var(--muted);
    color: var(--muted-foreground);
    font-size: 0.875rem;
  }

  .ripple-scene__media,
  .ripple-scene__scene,
  .ripple-scene__strip,
  .ripple-scene__image,
  .ripple-scene__scrim {
    position: absolute;
    inset: 0;
  }

  .ripple-scene__media {
    z-index: -3;
    overflow: hidden;
    background: oklch(0.12 0 0);
  }

  /* The outgoing scene never animates — it just sits beneath the wave. */
  .ripple-scene__scene--ripple {
    perspective: 60rem;
  }

  /*
   * Each strip carries the new image with the old image stacked on top. Before
   * its delay elapses the strip is flat and shows the old face, so the surface
   * starts out identical to the base scene — the wave then lifts each strip
   * into the corrugation, the faces cross over at the crest, and the strip
   * lands flat as part of the new scene.
   */
  .ripple-scene__strip {
    --ripple-scene-tilt: 13deg;
    --ripple-scene-lift: 2.4rem;
    --ripple-scene-dip: 0.55%;

    will-change: transform;
    animation: ripple-scene-strip var(--ripple-scene-duration)
      cubic-bezier(0.36, 0, 0.22, 1) both;
    animation-delay: var(--ripple-scene-delay, 0ms);
  }

  /* Alternating tilt and depth turn the travelling wave into a corrugation. */
  .ripple-scene__strip:nth-child(even) {
    --ripple-scene-tilt: -13deg;
    --ripple-scene-lift: -2.4rem;
    --ripple-scene-dip: -0.55%;
  }

  /* Old face rides the strip and hands over to the new face at the crest. */
  .ripple-scene__face--out {
    animation: ripple-scene-swap var(--ripple-scene-duration) linear both;
    animation-delay: var(--ripple-scene-delay, 0ms);
  }

  /* Corrugation lighting: raised strips catch light, sunken strips shade. */
  .ripple-scene__strip::after {
    content: "";
    position: absolute;
    inset: 0;
    background: rgb(255 255 255 / 14%);
    opacity: 0;
    animation: ripple-scene-shade var(--ripple-scene-duration)
      cubic-bezier(0.36, 0, 0.22, 1) both;
    animation-delay: var(--ripple-scene-delay, 0ms);
  }

  .ripple-scene__strip:nth-child(even)::after {
    background: rgb(0 0 0 / 26%);
  }

  @keyframes ripple-scene-strip {
    0% {
      transform: translate3d(0, 0, 0) rotateY(0deg);
      animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1);
    }
    45% {
      transform: translate3d(0, var(--ripple-scene-dip), var(--ripple-scene-lift))
        rotateY(var(--ripple-scene-tilt));
      animation-timing-function: cubic-bezier(0.34, 0, 0.22, 1);
    }
    100% {
      transform: translate3d(0, 0, 0) rotateY(0deg);
    }
  }

  @keyframes ripple-scene-swap {
    0%,
    32% {
      opacity: 1;
    }
    60%,
    100% {
      opacity: 0;
    }
  }

  @keyframes ripple-scene-shade {
    0% {
      opacity: 0;
    }
    45% {
      opacity: 1;
    }
    100% {
      opacity: 0;
    }
  }

  .ripple-scene__image {
    width: 100%;
    height: 100%;
    user-select: none;
    object-fit: cover;
  }

  .ripple-scene__scrim {
    z-index: -2;
    background:
      linear-gradient(to top, rgb(0 0 0 / 78%), transparent 62%),
      linear-gradient(to right, rgb(0 0 0 / 72%), transparent 68%),
      linear-gradient(to bottom, rgb(0 0 0 / 42%), transparent 32%);
  }

  .ripple-scene__chrome {
    display: grid;
    height: 100%;
    min-height: inherit;
    grid-template-rows: auto 1fr auto;
    gap: 1.5rem;
    padding: 1.25rem;
  }

  .ripple-scene__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    font-size: 0.8125rem;
    font-weight: 550;
    color: rgb(255 255 255 / 82%);
  }

  .ripple-scene__content {
    display: flex;
    min-width: 0;
    align-items: center;
  }

  .ripple-scene__content-inner {
    width: min(100%, 48rem);
  }

  .ripple-scene__content-inner > * {
    animation: ripple-scene-rise var(--ripple-scene-content-duration)
      var(--ripple-scene-ease) both;
  }

  .ripple-scene__content-inner > :nth-child(2) {
    animation-delay: 55ms;
  }

  .ripple-scene__content-inner > :nth-child(3) {
    animation-delay: 110ms;
  }

  @keyframes ripple-scene-rise {
    from {
      opacity: 0;
      transform: translateY(14px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  .ripple-scene__overline {
    margin: 0 0 0.75rem;
    max-width: 58ch;
    font-size: 0.8125rem;
    font-weight: 600;
    line-height: 1.4;
    color: rgb(255 255 255 / 76%);
  }

  .ripple-scene__content h2 {
    margin: 0;
    max-width: 12ch;
    font-size: clamp(2.25rem, 11cqw, 5.5rem);
    font-weight: 620;
    line-height: 0.96;
    letter-spacing: -0.035em;
    text-wrap: balance;
  }

  .ripple-scene__description {
    margin: 1rem 0 0;
    max-width: 58ch;
    font-size: clamp(0.875rem, 2.8cqw, 1rem);
    line-height: 1.6;
    color: rgb(255 255 255 / 82%);
    text-wrap: pretty;
  }

  @container (min-width: 46rem) {
    .ripple-scene__chrome {
      gap: 2rem;
      padding: 1.75rem 2rem 1rem;
    }

    .ripple-scene__content {
      padding-left: clamp(1rem, 6cqw, 6rem);
    }
  }
}

/* The selector is a RailList; these classes restyle its slots for the stage.
   They stay outside the defaults layer on purpose: RailList styles the same
   slots with Tailwind utilities (min-h-11, px-3 py-2, text-sm, font-medium,
   text-muted-foreground, inset-x-3, top-0, ...), which would beat these
   declarations from the utilities layer. */
.ripple-scene__selector {
  border-top: 1px solid rgb(255 255 255 / 24%);
}

.ripple-scene__tab {
  min-height: 3rem;
  padding: 0.875rem 0.75rem 0.75rem;
  color: rgb(255 255 255 / 68%);
  font-size: 0.8125rem;
  font-weight: 550;
  line-height: 1.25;
}

.ripple-scene__tab:hover,
.ripple-scene__tab[data-active] {
  color: white;
}

/* Sit the indicator on the rail's top border. */
.ripple-scene__tab-indicator {
  top: -1px;
  right: 0.75rem;
  left: 0.75rem;
}

@container (min-width: 46rem) {
  .ripple-scene__tab {
    min-width: 8rem;
    padding-inline: 1rem;
  }

  .ripple-scene__tab-indicator {
    right: 1rem;
    left: 1rem;
  }
}

/* Motion guards override ordinary customization. Keep these outside the
   defaults layer. */
@media (prefers-reduced-motion: reduce) {
  .ripple-scene__strip,
  .ripple-scene__strip::after,
  .ripple-scene__face--out,
  .ripple-scene__content-inner > * {
    animation: none !important;
  }
}

More from ericts

Browse all components in this library →