skecher-ui
uimagazine-scrollersource: ok · hit
Magazine Scroller
A velocity-aware poster scroller with drag, wheel, spring, and progressive-blur treatments.
npx shadcn add "https://skecher-ui.com/r/magazine-scroller.json"free
Visual style
glass-blur
Interaction
drag
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files2 · source cached 10/8/2026, 6:00:25 PM
components/ui-components/magazine-scroller.tsx
"use client";
import {
type MotionValue,
motion,
useAnimationFrame,
useMotionValue,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ProgressiveBlur } from "../ui/progressive-blur";
type Poster = {
src: string;
alt?: string;
};
export type MagazineScrollerSpringConfig = {
stiffness?: number;
damping?: number;
mass?: number;
visualDuration?: number;
bounce?: number;
};
export type MagazineScrollerProps = {
images?: Poster[];
cardWidth?: number;
cardHeight?: number;
gap?: number;
slices?: number;
height?: number | string;
wheelSpeed?: number;
dragSpeed?: number;
autoSpeed?: number;
bendStrength?: number;
maxBend?: number;
lockWheel?: boolean;
positionSpring?: MagazineScrollerSpringConfig;
velocitySpring?: MagazineScrollerSpringConfig;
className?: string;
};
const DEFAULT_IMAGES: Poster[] = [
"image.jpeg",
"image1.jpeg",
"image2.jpg",
"image3.jpg",
"image4.jpeg",
"image5.jpeg",
"image6.jpeg",
"image7.jpeg",
"image8.jpeg",
"image9.jpeg",
"image10.jpeg",
"image11.jpeg",
].map((file, index) => ({
src: `/images/magazine-scroller/${file}`,
alt: `Magazine poster ${index + 1}`,
}));
const POSITION_SPRING = {
mass: 0.28,
stiffness: 95,
damping: 24,
} satisfies MagazineScrollerSpringConfig;
const VELOCITY_SPRING = {
mass: 0.18,
stiffness: 80,
damping: 34,
} satisfies MagazineScrollerSpringConfig;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function wrap(min: number, max: number, value: number) {
const range = max - min;
if (range === 0) return min;
return ((((value - min) % range) + range) % range) + min;
}
function toCssSize(value: number | string) {
return typeof value === "number" ? `${value}px` : value;
}
export function MagazineScroller({
images,
cardWidth = 170,
cardHeight = 250,
gap = 34,
slices = 9,
height = "70vh",
wheelSpeed = 1.15,
dragSpeed = 1.15,
autoSpeed = 0,
bendStrength = 82,
maxBend = 100,
lockWheel = true,
positionSpring = POSITION_SPRING,
velocitySpring = VELOCITY_SPRING,
className,
}: MagazineScrollerProps) {
const rootRef = useRef<HTMLElement | null>(null);
const dragStartXRef = useRef(0);
const dragStartValueRef = useRef(0);
const isDraggingRef = useRef(false);
const [containerWidth, setContainerWidth] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const targetX = useMotionValue(0);
const x = useSpring(targetX, positionSpring);
const safeImages = useMemo(() => {
return images?.length ? images : DEFAULT_IMAGES;
}, [images]);
const safeSlices = Math.max(1, Math.floor(slices));
const itemStep = cardWidth + gap;
const loopWidth = safeImages.length * itemStep;
const loopX = useTransform(x, (latest) => {
return wrap(-loopWidth, 0, latest);
});
const velocity = useVelocity(x);
const smoothVelocity = useSpring(velocity, velocitySpring);
const bend = useTransform(smoothVelocity, (latest) => {
return clamp(latest / bendStrength, -maxBend, maxBend);
});
useEffect(() => {
const node = rootRef.current;
if (!node) return;
const updateSize = () => {
setContainerWidth(node.clientWidth);
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
const node = rootRef.current;
if (!node || !lockWheel) return;
const handleWheel = (event: WheelEvent) => {
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!delta) return;
event.preventDefault();
targetX.set(targetX.get() - delta * wheelSpeed);
};
node.addEventListener("wheel", handleWheel, { passive: false });
return () => {
node.removeEventListener("wheel", handleWheel);
};
}, [lockWheel, targetX, wheelSpeed]);
useAnimationFrame((_, delta) => {
if (!autoSpeed || isDraggingRef.current) return;
targetX.set(targetX.get() - autoSpeed * (delta / 1000));
});
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
isDraggingRef.current = true;
setIsDragging(true);
dragStartXRef.current = event.clientX;
dragStartValueRef.current = targetX.get();
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!isDraggingRef.current) return;
const distance = event.clientX - dragStartXRef.current;
targetX.set(dragStartValueRef.current + distance * dragSpeed);
};
const stopDragging = (event: ReactPointerEvent<HTMLElement>) => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
setIsDragging(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Pointer may already be released by the browser.
}
};
// === ProgressiveBlur implementation ===
// We'll render a left and right <ProgressiveBlur> element overlayed on top
// with pointerEvents: "none" so they do not block scroll/drag.
return (
<section
ref={rootRef}
className={className}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
style={{
position: "relative",
height: toCssSize(height),
width: "100%",
overflow: "hidden",
display: "flex",
alignItems: "center",
cursor: isDragging ? "grabbing" : "grab",
userSelect: "none",
touchAction: "pan-y",
perspective: 1400,
}}
>
{/* LEFT progressive blur */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: 56,
height: "100%",
zIndex: 2,
pointerEvents: "none",
}}
>
<ProgressiveBlur position="right" width="100%" height="100%" />
</div>
<motion.div
style={{
x: loopX,
display: "flex",
alignItems: "center",
gap,
transformStyle: "preserve-3d",
willChange: "transform",
}}
>
{[0, 1, 2].map((copyIndex) =>
safeImages.map((image, index) => {
const absoluteIndex = copyIndex * safeImages.length + index;
return (
<PosterCard
key={`${copyIndex}-${image.src}`}
image={image}
absoluteIndex={absoluteIndex}
loopX={loopX}
bend={bend}
cardWidth={cardWidth}
cardHeight={cardHeight}
itemStep={itemStep}
slices={safeSlices}
containerWidth={containerWidth}
/>
);
}),
)}
</motion.div>
<div
style={{
position: "absolute",
top: 0,
right: 0,
width: 56,
height: "100%",
zIndex: 2,
pointerEvents: "none",
}}
>
<ProgressiveBlur position="left" width="100%" height="100%" />
</div>
</section>
);
}
type PosterCardProps = {
image: Poster;
absoluteIndex: number;
loopX: MotionValue<number>;
bend: MotionValue<number>;
cardWidth: number;
cardHeight: number;
itemStep: number;
slices: number;
containerWidth: number;
};
function PosterCard({
image,
absoluteIndex,
loopX,
bend,
cardWidth,
cardHeight,
itemStep,
slices,
containerWidth,
}: PosterCardProps) {
const cardCenter = useTransform(loopX, (latestX) => {
return latestX + absoluteIndex * itemStep + cardWidth / 1;
});
const rotateY = useTransform(cardCenter, (center) => {
if (!containerWidth) return 0;
const viewportCenter = containerWidth / 2;
const distance = (center - viewportCenter) / viewportCenter;
return clamp(distance * -62, -76, 76);
});
const scale = useTransform(cardCenter, (center) => {
if (!containerWidth) return 1;
const viewportCenter = containerWidth / 2;
const distance = Math.abs(center - viewportCenter) / viewportCenter;
return clamp(1 - distance * 0.15, 0.82, 1);
});
const opacity = useTransform(cardCenter, (center) => {
if (!containerWidth) return 1;
const viewportCenter = containerWidth / 2;
const distance = Math.abs(center - viewportCenter) / viewportCenter;
return clamp(1 - distance * 0.38, 0.48, 1);
});
const y = useTransform(cardCenter, (center) => {
if (!containerWidth) return 0;
const viewportCenter = containerWidth / 2;
const distance = Math.abs(center - viewportCenter) / viewportCenter;
return clamp(distance * 30, 0, 44);
});
const rotateZ = useTransform(bend, (latest) => latest * 0.08);
return (
<motion.article
aria-label={image.alt ?? "Magazine poster"}
style={{
width: cardWidth,
height: cardHeight,
flex: "0 0 auto",
y,
rotateY,
rotateZ,
scale,
opacity,
transformPerspective: 1200,
transformStyle: "preserve-3d",
willChange: "transform",
}}
>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
overflow: "hidden",
borderRadius: 4,
transformStyle: "preserve-3d",
}}
>
{Array.from({ length: slices }).map((_, sliceIndex) => (
<PosterSlice
// The generated slice position is its stable identity within a poster.
// biome-ignore lint/suspicious/noArrayIndexKey: Slices do not reorder.
key={`${image.src}-${sliceIndex}`}
src={image.src}
sliceIndex={sliceIndex}
slices={slices}
bend={bend}
/>
))}
</div>
</motion.article>
);
}
type PosterSliceProps = {
src: string;
sliceIndex: number;
slices: number;
bend: MotionValue<number>;
};
function PosterSlice({ src, sliceIndex, slices, bend }: PosterSliceProps) {
const middle = (slices - 1) / 2;
const offsetFromMiddle = sliceIndex - middle;
const sliceRotateY = useTransform(bend, (latest) => {
return latest * offsetFromMiddle * 0.22;
});
const sliceSkewY = useTransform(bend, (latest) => {
return latest * offsetFromMiddle * 0.014;
});
const sliceZ = useTransform(bend, (latest) => {
return Math.abs(latest) * Math.abs(offsetFromMiddle) * -0.18;
});
const backgroundPosition = slices === 1 ? "50% 50%" : `${(sliceIndex / (slices - 1)) * 100}% 50%`;
return (
<motion.div
style={{
width: `${100 / slices}%`,
height: "100%",
rotateY: sliceRotateY,
skewY: sliceSkewY,
z: sliceZ,
transformOrigin: "center center",
backgroundImage: `url(${src})`,
backgroundSize: `${slices * 100}% 100%`,
backgroundPosition,
backgroundRepeat: "no-repeat",
backfaceVisibility: "hidden",
willChange: "transform",
}}
/>
);
}
export default MagazineScroller;
components/ui/progressive-blur.tsx
"use client";
import type React from "react";
import { cn } from "@/lib/utils";
export interface ProgressiveBlurProps {
className?: string;
height?: string;
width?: string;
position?: "top" | "bottom" | "left" | "right" | "both";
blurLevels?: number[];
children?: React.ReactNode;
}
export function ProgressiveBlur({
className,
height = "30%",
width = "30%",
position = "bottom",
blurLevels = [0.5, 1, 2, 4, 8, 16, 32, 64],
}: ProgressiveBlurProps) {
const divElements = Array(blurLevels.length - 2).fill(null);
const isHorizontal = position === "left" || position === "right";
const fullMask =
"linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)";
return (
<div
className={cn(
"gradient-blur pointer-events-none absolute z-10",
position === "top"
? "top-0"
: position === "bottom"
? "bottom-0"
: position === "left"
? "left-0"
: position === "right"
? "right-0"
: "inset-y-0",
isHorizontal ? "inset-y-0" : "inset-x-0",
className,
)}
style={{
height: isHorizontal || position === "both" ? "100%" : height,
width: isHorizontal ? width : undefined,
}}
>
<div
className="absolute inset-0"
style={{
zIndex: 1,
backdropFilter: `blur(${blurLevels[0]}px)`,
WebkitBackdropFilter: `blur(${blurLevels[0]}px)`,
maskImage:
position === "bottom"
? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: position === "top"
? "linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: position === "left"
? "linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: position === "right"
? "linear-gradient(to left, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: fullMask,
WebkitMaskImage:
position === "bottom"
? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: position === "top"
? "linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: position === "left"
? "linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: position === "right"
? "linear-gradient(to left, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)"
: fullMask,
}}
/>
{divElements.map((_, index) => {
const blurIndex = index + 1;
const startPercent = blurIndex * 12.5;
const midPercent = (blurIndex + 1) * 12.5;
const endPercent = (blurIndex + 2) * 12.5;
const maskGradient =
position === "bottom"
? `linear-gradient(to bottom, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${
endPercent + 12.5
}%)`
: position === "top"
? `linear-gradient(to top, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${
endPercent + 12.5
}%)`
: position === "left"
? `linear-gradient(to right, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${
endPercent + 12.5
}%)`
: position === "right"
? `linear-gradient(to left, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${
endPercent + 12.5
}%)`
: fullMask;
return (
<div
key={`blur-${blurLevels[blurIndex]}`}
className="absolute inset-0"
style={{
zIndex: index + 2,
backdropFilter: `blur(${blurLevels[blurIndex]}px)`,
WebkitBackdropFilter: `blur(${blurLevels[blurIndex]}px)`,
maskImage: maskGradient,
WebkitMaskImage: maskGradient,
}}
/>
);
})}
<div
className="absolute inset-0"
style={{
zIndex: blurLevels.length,
backdropFilter: `blur(${blurLevels[blurLevels.length - 1]}px)`,
WebkitBackdropFilter: `blur(${blurLevels[blurLevels.length - 1]}px)`,
maskImage:
position === "bottom"
? "linear-gradient(to bottom, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: position === "top"
? "linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: position === "left"
? "linear-gradient(to right, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: position === "right"
? "linear-gradient(to left, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: fullMask,
WebkitMaskImage:
position === "bottom"
? "linear-gradient(to bottom, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: position === "top"
? "linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: position === "left"
? "linear-gradient(to right, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: position === "right"
? "linear-gradient(to left, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)"
: fullMask,
}}
/>
</div>
);
}