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 · lightRegistry 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;
}
}