A split-image carousel for portfolios, campaign pages, product showcases, editorial visuals, and brand-led landing sections where image transitions should feel designed rather than simply swapped.

Parallax Strip Slider turns a normal image slider into a layered motion sequence.
Instead of moving one flat image in and out, the visual can be divided into strips that travel, offset, or resolve at slightly different speeds. This creates a parallax-like handoff between slides, where the image feels rebuilt through layers rather than replaced in one plain movement.
Use Parallax Strip Slider when a visual sequence deserves more craft than a standard carousel. It works for campaign stills, product photography, portfolio projects, fashion lookbooks, editorial spreads, launch visuals, agency case studies, and image-led product sections.
The page job is cinematic transition. The visitor should feel the image change, understand the selected slide, and keep browsing without losing orientation. The strip motion should make the handoff memorable, not make the image feel like it was shredded for decoration.
npx hyperiux add parallax-strip-slider
import ParallaxStripSlider from "@/components/effects/parallax-strip-slider";
export default function page() {
return (
<>
<ParallaxStripSlider />
</>
);
}// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import Image from "next/image";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { SplitText } from "gsap/SplitText";
if (typeof window !== "undefined") {
gsap.registerPlugin(SplitText);
}
// Animation timing
const STRIP_COUNT = 12;
const REVEAL_DURATION = 0.5;
const STRIP_STAGGER = 0.04;
const ZOOM_DURATION = 0.9;
const ZOOM_FROM = 1.2;
const AUTOPLAY_INTERVAL = 5000;
const TITLE_CHAR_DURATION = 0.6;
const TITLE_CHAR_STAGGER = 0.04;
const TITLE_CHAR_Y_PERCENT = 100;
const PROGRESS_DURATION = 0.9;
function prefersReducedMotion() {
return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
}
export default function ParallaxStripSlider({ slides, className = "", stripCount = STRIP_COUNT, revealDuration = REVEAL_DURATION, stripStagger = STRIP_STAGGER, zoomFrom = ZOOM_FROM, zoomDuration = ZOOM_DURATION, autoplay = false, showProgressBar = true, showCounter = true, showControls = true, showClue = true, clueText = "Click either side and watch the next strip cut through.", accentColor = "#ffffff", }) {
const [current, setCurrent] = useState(0);
const [incoming, setIncoming] = useState(null);
const [caption, setCaption] = useState(0);
const [direction, setDirection] = useState("next");
const rootRef = useRef(null);
const captionRef = useRef(null);
const chapterRef = useRef(null);
const titleRef = useRef(null);
const counterRef = useRef(null);
const counterNumRef = useRef(null);
const progressRef = useRef(null);
const stripsRef = useRef([]);
const zoomRef = useRef([]);
const isAnimating = useRef(false);
const isFirstCaption = useRef(true);
const splitRef = useRef(null);
// Circular click-to-navigate cursor.
const cursorRef = useRef(null);
const line1Ref = useRef(null);
const line2Ref = useRef(null);
const isInside = useRef(false);
const mouse = useRef({ x: 0, y: 0 });
const pos = useRef({ x: 0, y: 0 });
const total = slides.length;
const goTo = useCallback((next, transitionDirection) => {
if (isAnimating.current || next === current || total < 2)
return;
isAnimating.current = true;
setDirection(transitionDirection);
setIncoming(next);
}, [current, total]);
const onNext = useCallback(() => goTo((current + 1) % total, "next"), [current, total, goTo]);
const onPrev = useCallback(() => goTo((current - 1 + total) % total, "prev"), [current, total, goTo]);
// Auto-advance on a timer; pauses while a transition is mid-flight and when
// reduced motion is requested.
useEffect(() => {
if (!autoplay || total < 2)
return;
if (typeof window !== "undefined" && prefersReducedMotion())
return;
const id = window.setInterval(() => {
if (!isAnimating.current)
onNext();
}, AUTOPLAY_INTERVAL);
return () => window.clearInterval(id);
}, [autoplay, total, onNext]);
// Wipe + zoom + progress on slide change.
useGSAP(() => {
if (incoming === null)
return;
const strips = stripsRef.current.slice(0, stripCount).filter(Boolean);
const zooms = zoomRef.current.slice(0, stripCount).filter(Boolean);
if (!strips.length)
return;
const isPrevious = direction === "prev";
const orderedStrips = isPrevious ? [...strips].reverse() : strips;
// Reduced motion: swap without the reveal.
if (prefersReducedMotion()) {
setCaption(incoming);
setCurrent(incoming);
setIncoming(null);
isAnimating.current = false;
return;
}
const settle = () => {
setCaption(incoming);
setCurrent(incoming);
setIncoming(null);
isAnimating.current = false;
};
const tl = gsap.timeline({ onComplete: settle });
// Reverse stagger + clip origin when moving backward.
tl.fromTo(orderedStrips, { clipPath: isPrevious ? "inset(0 0 0 100%)" : "inset(0 100% 0 0)" }, {
clipPath: isPrevious ? "inset(0 0 0 0%)" : "inset(0 0% 0 0)",
duration: revealDuration,
ease: "power3.out",
stagger: stripStagger,
}, 0);
tl.fromTo(zooms, { scale: zoomFrom }, {
scale: 1,
duration: zoomDuration,
ease: "power3.out",
}, 0);
// Bar slides toward the incoming fill.
if (progressRef.current) {
tl.to(progressRef.current, {
scaleX: (incoming + 1) / total,
duration: PROGRESS_DURATION,
ease: "power3.inOut",
}, 0);
}
// Outgoing caption texts fade out under the strips.
const outgoing = [
captionRef.current,
titleRef.current,
counterRef.current,
].filter(Boolean);
if (outgoing.length) {
tl.to(outgoing, {
autoAlpha: 0,
y: -2,
duration: 0.35,
ease: "power2.in",
}, 0.15);
tl.add(() => setCaption(incoming), 0.5);
}
}, { dependencies: [incoming, direction, stripCount, revealDuration, stripStagger, zoomFrom, zoomDuration], scope: rootRef });
// Revert the split before React commits the new title.
useLayoutEffect(() => {
splitRef.current?.revert();
splitRef.current = null;
}, [caption]);
// Incoming caption reveal: title chars, chapter fade, counter number.
useGSAP(() => {
if (isFirstCaption.current) {
isFirstCaption.current = false;
return;
}
if (!captionRef.current || !titleRef.current)
return;
// Snap back what the outgoing tween hid.
gsap.set([captionRef.current, titleRef.current], { autoAlpha: 1, y: 0 });
if (prefersReducedMotion()) {
gsap.set([chapterRef.current, titleRef.current, counterRef.current, counterNumRef.current], { autoAlpha: 1, y: 0, yPercent: 0 });
return;
}
// Chars only — no line/mask wrappers that shift metrics.
const split = new SplitText(titleRef.current, { type: "chars" });
splitRef.current = split;
const tl = gsap.timeline({
// Restore plain <h2> once the reveal is done.
onComplete: () => {
split.revert();
if (splitRef.current === split)
splitRef.current = null;
},
});
// Title: char stagger up from below the clip.
tl.from(split.chars, {
yPercent: TITLE_CHAR_Y_PERCENT,
duration: TITLE_CHAR_DURATION,
ease: "power2.out",
stagger: TITLE_CHAR_STAGGER,
}, 0);
// Chapter: fade only.
if (chapterRef.current) {
tl.fromTo(chapterRef.current, { autoAlpha: 0, y: 0 }, { autoAlpha: 1, y: 0, duration: 0.5, ease: "power2.out" }, 0);
}
// Counter: fade block, lift just the number.
if (counterRef.current) {
tl.fromTo(counterRef.current, { autoAlpha: 0, y: 0 }, { autoAlpha: 1, y: 0, duration: 0.45, ease: "power2.out" }, 0);
}
if (counterNumRef.current) {
tl.from(counterNumRef.current, {
yPercent: 110,
duration: 0.55,
ease: "power3.out",
}, 0);
}
}, { dependencies: [caption], scope: rootRef });
// Drop the last live split on unmount.
useGSAP(() => () => {
splitRef.current?.revert();
splitRef.current = null;
}, { scope: rootRef });
// Circular cursor: smooth follow + arrow that flips with the pointer side.
// Mirrors the clip-path-slider interaction (0.12 lerp, power3 eases).
useEffect(() => {
if (!showControls)
return;
const cursor = cursorRef.current;
const l1 = line1Ref.current;
const l2 = line2Ref.current;
if (!cursor || !l1 || !l2)
return;
gsap.set(cursor, { xPercent: -50, yPercent: -50, opacity: 0, scale: 0.6 });
gsap.set(l1, {
transformOrigin: "100% 50%",
xPercent: -50,
yPercent: -50,
y: -1.5,
rotation: 45,
x: 0,
});
gsap.set(l2, {
transformOrigin: "100% 50%",
xPercent: -50,
yPercent: -50,
y: 1.5,
rotation: -45,
x: 0,
});
let currentSide = "right";
let rafId = null;
const handleMove = (e) => {
const x = e.clientX;
const y = e.clientY;
const target = e.target instanceof Element ? e.target : null;
const isOverControls = Boolean(target?.closest('button, input, textarea, select, a, label, [role="button"], [contenteditable="true"], [class*="remixer-panel"]'));
mouse.current.x = x;
mouse.current.y = y;
const rect = rootRef.current?.getBoundingClientRect();
const isOut = !rect ||
x <= rect.left ||
y <= rect.top ||
x >= rect.right ||
y >= rect.bottom;
if (isOut || isOverControls) {
if (isInside.current) {
isInside.current = false;
gsap.to(cursor, {
opacity: 0,
scale: 0.6,
duration: 0.25,
ease: "power3.inOut",
});
}
return;
}
if (!isInside.current) {
pos.current.x = x;
pos.current.y = y;
gsap.set(cursor, { x, y });
gsap.to(cursor, {
opacity: 1,
scale: 1,
duration: 0.25,
ease: "power3.out",
});
isInside.current = true;
}
const isLeft = rect ? x < rect.left + rect.width / 2 : false;
const nextSide = isLeft ? "left" : "right";
if (nextSide !== currentSide) {
currentSide = nextSide;
if (nextSide === "left") {
gsap.to(l1, { rotation: 135, x: "-1vw", duration: 0.35, ease: "power3.inOut" });
gsap.to(l2, { rotation: -135, x: "-1vw", duration: 0.35, ease: "power3.inOut" });
}
else {
gsap.to(l1, { rotation: 45, x: 4, duration: 0.35, ease: "power3.inOut" });
gsap.to(l2, { rotation: -45, x: 4, duration: 0.35, ease: "power3.inOut" });
}
}
};
const render = () => {
pos.current.x += (mouse.current.x - pos.current.x) * 0.12;
pos.current.y += (mouse.current.y - pos.current.y) * 0.12;
gsap.set(cursor, { x: pos.current.x, y: pos.current.y });
rafId = requestAnimationFrame(render);
};
window.addEventListener("mousemove", handleMove);
render();
return () => {
window.removeEventListener("mousemove", handleMove);
if (rafId)
cancelAnimationFrame(rafId);
};
}, [showControls]);
const renderStrips = (slide) => {
const width = 100 / stripCount;
return Array.from({ length: stripCount }, (_, i) => (<div key={i} ref={(el) => {
if (el)
stripsRef.current[i] = el;
}} className="absolute inset-y-0 overflow-hidden" style={{
left: `${i * width}%`,
width: `${width}%`,
// Cover the seam between neighbours.
marginLeft: i === 0 ? 0 : "-0.5px",
paddingLeft: i === 0 ? 0 : "0.5px",
}}>
{/* Full-width image pulled back by this strip's offset. */}
<div className="absolute inset-y-0" style={{
left: `-${i * 100}%`,
width: `${stripCount * 100}%`,
}}>
<div ref={(el) => {
if (el)
zoomRef.current[i] = el;
}} className="relative h-full w-full will-change-transform">
<Image src={slide.src} alt="" fill sizes="100vw" draggable={false} className="object-cover select-none"/>
</div>
</div>
</div>));
};
const activeSlide = slides[caption];
return (<div ref={rootRef} className={`parallax-strip-slider relative h-full w-full overflow-hidden bg-black ${className}`}>
{/* Outgoing slide, revealed away underneath. */}
<div className="absolute inset-0">
<Image src={slides[current].src} alt={slides[current].title} fill priority sizes="100vw" draggable={false} className="object-cover select-none"/>
</div>
{/* Incoming slide, mounted only during a transition. */}
{incoming !== null && (<div className="absolute inset-0">{renderStrips(slides[incoming])}</div>)}
{/* Click-to-navigate overlay: left half steps back, right half advances. */}
{showControls && total > 1 && (<div className="absolute inset-0 z-20" style={{ cursor: "none" }} onClick={(e) => {
if (isAnimating.current)
return;
const rect = e.currentTarget.getBoundingClientRect();
const isLeft = e.clientX < rect.left + rect.width / 2;
if (isLeft)
onPrev();
else
onNext();
}}/>)}
{/* Top progress bar, floating over the image. Pushed clear of the fixed
demo header. */}
{showProgressBar && (<div className="pointer-events-none absolute inset-x-10 top-20 z-10 h-px max-[1025px]:top-28" style={{ backgroundColor: `${accentColor}33` }}>
<div ref={progressRef} className="h-full w-full origin-left" style={{
transform: `scaleX(${(caption + 1) / total})`,
backgroundColor: accentColor,
}}/>
</div>)}
{/* Top-left label */}
<div ref={captionRef} className="pointer-events-none absolute inset-x-0 top-0 px-10 pb-10 pt-24 max-[1025px]:pt-32">
<span ref={chapterRef} className="block text-xs font-medium tracking-wide" style={{ color: accentColor }}>
{activeSlide.chapter ??
`Collection ${String(caption + 1).padStart(2, "0")}`}
</span>
</div>
{/* Bottom bar: three fixed thirds on one line; on mobile it stacks with
the title + counter above and the controls below. */}
<div className="absolute inset-x-0 bottom-0 flex items-end px-10 pb-10 max-[1025px]:flex-col max-[1025px]:items-stretch max-[1025px]:gap-6 max-md:pb-32">
<h2 ref={titleRef} className="pointer-events-none w-1/3 shrink-0 overflow-hidden text-8xl leading-none flex items-end max-[1025px]:order-1 max-[1025px]:w-full" style={{
fontFamily: '"Instrument Serif", Georgia, serif',
color: accentColor,
}}>
{activeSlide.title}
</h2>
{/* Middle third keeps its width so the counter stays pinned to the
right. On mobile the spacer collapses. */}
<div aria-hidden className="w-1/3 shrink-0 max-[1025px]:hidden"/>
{showCounter && (<span ref={counterRef} className="pointer-events-none flex w-1/3 pb-5 shrink-0 h-full items-center pt-5! justify-end text-xs max-[1025px]:order-2 max-[1025px]:h-auto max-[1025px]:w-full max-[1025px]:justify-start max-[1025px]:p-0 max-md:order-first max-md:justify-end" style={{ color: `${accentColor}b3` }}>
{/* Fixed-width clip so the number can lift in without reflow. */}
<span className="inline-block w-[2ch] overflow-hidden text-right">
<span ref={counterNumRef} className="inline-block">
{String(caption + 1).padStart(2, "0")}
</span>
</span>
<span> / {String(total).padStart(2, "0")}</span>
</span>)}
</div>
{/* Circular nav cursor — follows the pointer, arrow flips per side. */}
{showControls && total > 1 && (<div ref={cursorRef} className="pointer-events-none fixed left-0 top-0 z-100 max-[1025px]:hidden">
<div className="flex size-15 items-center justify-center rounded-full" style={{ backgroundColor: accentColor }}>
<div className="relative size-7.5">
<span ref={line1Ref} className="absolute left-1/2 top-1/2 h-0.5 w-4" style={{ backgroundColor: "#000000" }}/>
<span ref={line2Ref} className="absolute left-1/2 top-1/2 h-0.5 w-4" style={{ backgroundColor: "#000000" }}/>
</div>
</div>
</div>)}
{showClue && clueText && total > 1 && (<p className="pointer-events-none absolute bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-full border border-white/15 bg-black/35 px-6 py-3 text-center font-mono text-sm leading-relaxed text-white/80 backdrop-blur-md max-md:bottom-6 max-md:w-[70%] max-md:text-sm max-[1025px]:text-base">
{clueText}
</p>)}
<style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap");
`}</style>
</div>);
}
A portfolio page can use Parallax Strip Slider to present selected case studies. Each project image transitions through layered strips, giving the work a cinematic reveal before the title and CTA appear.
The outcome is polish. The section feels more authored than a static grid, but it still keeps the visitor focused on the project rather than the transition mechanic.
A product company can use the same component to showcase template previews, feature visuals, dashboard states, or launch imagery where each slide needs a premium handoff.
• Portfolio highlights and case-study showcases.
• Campaign visuals, editorial spreads, and launch imagery.
• Product renders, dashboard previews, and template galleries.
• Fashion, lifestyle, visual identity, and photography-led pages.
• Hero sections where slide transitions need more depth than a flat fade.
Not for dense catalogues, pricing tables, documentation, testimonial lists, product comparison grids, or content users need to scan quickly.
Not for weak images. Strip motion can make a good image feel premium; it cannot rescue a mediocre asset wearing too much transition perfume.
Not for pages where users need immediate image comparison. Use a grid when comparison matters more than presentation.
Keep the slide count controlled, optimise images, reserve carousel dimensions, and limit strip count. Animate transform and opacity rather than layout properties. Avoid heavy filters, oversized images, and unnecessary per-frame recalculation.
Do not render full-resolution images for every strip when smaller display sizes are enough. A strip slider should feel fluid before it feels elaborate.
Use real buttons for previous and next controls. Provide visible focus states, accessible labels, and predictable keyboard behavior. Keep slide order logical and ensure captions remain readable outside the visual transition.
On mobile, reduce strip count, parallax distance, and transition duration. For reduced motion, switch slides with a fade, instant state change, or minimal translate without segmented movement.
• Using too many strips.
• Letting the transition hide the image for too long.
• Mixing inconsistent image ratios without a cropping rule.
• Making navigation unclear.
• Hiding meaningful text inside image assets.
• Keeping autoplay active near important reading or conversion sections.
• Treating the strip effect as the content instead of the handoff.
| Prop | Type | Default | Description |
|---|---|---|---|
stripCount | number | 12 | Number of vertical strips the incoming slide is revealed through. |
revealDuration | number | 0.5 | Duration of each strip's clip-path wipe, in seconds. |
stripStagger | number | 0.04 | Delay between consecutive strips as they wipe in, in seconds. |
zoomFrom | number | 1.2 | Starting scale of the incoming image before it settles to 1 (Ken-Burns zoom). |
zoomDuration | number | 0.9 | Duration of the image zoom settle, in seconds. |
autoplay | boolean | false | Auto-advance slides on a 5s timer. Paused while a transition is running and under reduced motion. |
showProgressBar | boolean | true | Show the thin progress bar across the top of the slider. |
showCounter | boolean | true | Show the numeric slide counter in the bottom-right. |
showControls | boolean | true | Enable the click-to-navigate overlay and its circular cursor (left half = previous, right half = next). |
showClue | boolean | true | Show the hint pill telling the viewer to click either side to navigate. |
accentColor | color | #ffffff | Color of the slide title, progress bar fill, caption text, counter, and control borders. |
Parallax Strip Slider is a React image carousel effect that transitions between slides using segmented image strips with layered parallax-style movement.
Use it for portfolios, campaign visuals, product showcases, editorial imagery, template previews, and image-led sections where slide transitions should feel premium.
No. It works best for curated image sets. Large galleries usually need filtering, grid browsing, lazy loading, or pagination.
Yes. Captions work well when they are short and placed outside or consistently over the image. Keep important text as real HTML where possible.
Use semantic navigation buttons, visible focus states, accessible labels, meaningful image alt text, and real DOM captions. Do not rely only on visual motion to communicate slide changes.
Reduced motion should remove segmented strip movement and parallax travel. Use a simple fade, instant switch, or minimal slide transition.
Yes, with tuning. Reduce strip count, transition distance, and visual complexity so the slider remains smooth and readable on smaller screens.
Yes. A custom version should define strip direction, image ratios, slide timing, navigation behavior, mobile fallback, accessibility states, reduced-motion handling, source handoff, and implementation notes.
Need a custom effect? Tell us what to create.