Wheels
Radial Scroll Gallery
A scroll-driven interaction that rotates items along a large, partially hidden circle.
Basic Usage
A clean, process-oriented layout using standard UI cards. This example demonstrates how to build a timeline or workflow visualization where the active step is highlighted using Shadcn tokens (bg-primary, text-primary-foreground).
Portfolio & Image Gallery
An immersive showcase designed for high-fidelity media. This example uses complex item layouts with hover effects, z-index layering, and "glassmorphism" details to create a premium "stack of cards" feel suitable for portfolios.
Installation
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/radial-scroll-gallery.jsonManual
1. Install Dependencies:
npm install gsap @gsap/react
# or
yarn add gsap @gsap/react
# or
pnpm add gsap @gsap/react2. Copy the Component Code:
'use client';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import React, {
forwardRef,
HTMLAttributes,
ReactNode,
Ref,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
if (typeof window !== 'undefined') {
gsap.registerPlugin(ScrollTrigger);
}
function useMergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
return useMemo(() => {
if (refs.every((ref) => ref == null)) return null;
return (node: T) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node);
} else if (ref != null) {
(ref as React.MutableRefObject<T | null>).current = node;
}
});
};
}, [refs]);
}
function useResponsiveValue(baseValue: number, mobileValue: number) {
const [value, setValue] = useState(baseValue);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
setValue(window.innerWidth < 768 ? mobileValue : baseValue);
};
handleResize();
let timeoutId: NodeJS.Timeout;
const debouncedResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(handleResize, 100);
};
window.addEventListener('resize', debouncedResize);
return () => {
window.removeEventListener('resize', debouncedResize);
clearTimeout(timeoutId);
};
}, [baseValue, mobileValue]);
return value;
}
export interface RadialScrollGalleryProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
/**
* Render function that returns the array of items to be placed on the wheel.
* Receives the currently `hoveredIndex` to allow for parent-controlled hover states.
*/
children: (hoveredIndex: number | null) => ReactNode[];
/**
* The vertical scroll distance (in pixels) required to complete one full 360-degree rotation.
* Defaults to 2500.
*/
scrollDuration?: number;
/**
* Percentage of the circle visible above the fold (0-100).
* Determines how "deep" the wheel is buried. Defaults to 45.
*/
visiblePercentage?: number;
/** Radius of the circle on desktop devices (>=768px). */
baseRadius?: number;
/** Radius of the circle on mobile devices (<768px). */
mobileRadius?: number;
/**
* GSAP ScrollTrigger start position string (e.g., "top 80%", "center center").
*/
startTrigger?: string;
/** Callback fired when an item is clicked or selected via keyboard. */
onItemSelect?: (index: number) => void;
/** Rotational direction of the wheel. */
direction?: 'ltr' | 'rtl';
/** Disables all interactions and applies a grayscale effect. */
disabled?: boolean;
}
/**
* A scroll-driven interaction that rotates items along a large, partially hidden circle.
* The component pins itself to the viewport while the user scrolls through the rotational progress.
*/
export const RadialScrollGallery = forwardRef<
HTMLDivElement,
RadialScrollGalleryProps
>(
(
{
children,
scrollDuration = 2500,
visiblePercentage = 45,
baseRadius = 550,
mobileRadius = 220,
className = '',
startTrigger = 'center center',
onItemSelect,
direction = 'ltr',
disabled = false,
...rest
},
ref
) => {
const pinRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLUListElement>(null);
const childRef = useRef<HTMLLIElement>(null);
const mergedRef = useMergeRefs(ref, pinRef);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [childSize, setChildSize] = useState<{ w: number; h: number } | null>(
null
);
const [isMounted, setIsMounted] = useState(false);
const currentRadius = useResponsiveValue(baseRadius, mobileRadius);
const circleDiameter = currentRadius * 2;
const { visibleDecimal, hiddenDecimal } = useMemo(() => {
const clamped = Math.max(10, Math.min(100, visiblePercentage));
const v = clamped / 100;
return { visibleDecimal: v, hiddenDecimal: 1 - v };
}, [visiblePercentage]);
const childrenNodes = useMemo(
() => React.Children.toArray(children(hoveredIndex)),
[children, hoveredIndex]
);
const childrenCount = childrenNodes.length;
// Measure the first child to determine layout buffers.
// This ensures the container is tall enough to prevent clipping as items rotate.
useEffect(() => {
setIsMounted(true);
if (!childRef.current) return;
const observer = new ResizeObserver((entries) => {
let hasChanged = false;
for (const entry of entries) {
setChildSize({
w: entry.contentRect.width,
h: entry.contentRect.height,
});
hasChanged = true;
}
if (hasChanged) {
ScrollTrigger.refresh();
}
});
observer.observe(childRef.current);
return () => observer.disconnect();
}, [childrenCount]);
useGSAP(
() => {
if (!pinRef.current || !containerRef.current || childrenCount === 0)
return;
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (!prefersReducedMotion) {
gsap.fromTo(
containerRef.current.children,
{ scale: 0, autoAlpha: 0 },
{
scale: 1,
autoAlpha: 1,
duration: 1.2,
ease: 'back.out(1.2)',
stagger: 0.05,
scrollTrigger: {
trigger: pinRef.current,
start: 'top 80%',
toggleActions: 'play none none reverse',
},
}
);
gsap.to(containerRef.current, {
rotation: 360,
ease: 'none',
scrollTrigger: {
trigger: pinRef.current,
pin: true,
start: startTrigger,
end: `+=${scrollDuration}`,
scrub: 1,
invalidateOnRefresh: true,
},
});
}
},
{
scope: pinRef,
dependencies: [
scrollDuration,
currentRadius,
startTrigger,
childrenCount,
],
}
);
if (childrenCount === 0) return null;
// Calculate the total height required for the pinned container.
// We need (Visible Circle Height) + (Half Item Height) + (Buffer) to ensure items aren't cut off by the mask.
const scaleFactor = 1.25;
const calculatedBuffer = childSize
? childSize.h * scaleFactor - childSize.h + 60
: 150;
const visibleAreaHeight = childSize
? circleDiameter * visibleDecimal + childSize.h / 2 + calculatedBuffer
: circleDiameter * visibleDecimal + 200;
return (
<div
ref={mergedRef}
className={`min-h-screen w-full relative flex items-center justify-center overflow-hidden ${className}`}
{...rest}
>
<div
className='relative w-full overflow-hidden'
style={{
height: `${visibleAreaHeight}px`,
maskImage:
'linear-gradient(to top, transparent 0%, black 40%, black 100%)',
WebkitMaskImage:
'linear-gradient(to top, transparent 0%, black 40%, black 100%)',
}}
>
<ul
ref={containerRef}
className={`
absolute left-1/2 -translate-x-1/2 will-change-transform m-0 p-0 list-none
transition-opacity duration-500 ease-out
${disabled ? 'opacity-50 pointer-events-none grayscale' : ''}
${isMounted ? 'opacity-100' : 'opacity-0'}
`}
dir={direction}
style={{
width: circleDiameter,
height: circleDiameter,
bottom: -(circleDiameter * hiddenDecimal),
}}
>
{childrenNodes.map((child, index) => {
const angle = (index / childrenCount) * 2 * Math.PI;
let x = currentRadius * Math.cos(angle);
const y = currentRadius * Math.sin(angle);
if (direction === 'rtl') {
x = -x;
}
const rotationAngle = (angle * 180) / Math.PI;
const isHovered = hoveredIndex === index;
const isAnyHovered = hoveredIndex !== null;
return (
<li
key={index}
ref={index === 0 ? childRef : null}
className='absolute top-1/2 left-1/2'
style={{
zIndex: isHovered ? 100 : 10,
transform: `translate(-50%, -50%) translate3d(${x}px, ${y}px, 0) rotate(${
rotationAngle + 90
}deg)`,
}}
>
{/*
Using a generic div with role="button" instead of <button>
to allow passing interactive children (like <Link>) without creating invalid HTML nesting.
*/}
<div
role='button'
tabIndex={disabled ? -1 : 0}
onClick={() => !disabled && onItemSelect?.(index)}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onItemSelect?.(index);
}
}}
onMouseEnter={() => !disabled && setHoveredIndex(index)}
onMouseLeave={() => !disabled && setHoveredIndex(null)}
onFocus={() => !disabled && setHoveredIndex(index)}
onBlur={() => !disabled && setHoveredIndex(null)}
className={`
block cursor-pointer outline-none text-left
focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
rounded-xl transition-all duration-500 ease-out will-change-transform
${isHovered ? 'scale-125 -translate-y-8' : 'scale-100'}
${
isAnyHovered && !isHovered
? 'blur-[2px] opacity-40 grayscale'
: 'blur-0 opacity-100'
}
`}
>
{child}
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
);
RadialScrollGallery.displayName = 'RadialScrollGallery';Usage
The RadialScrollGallery uses a render-prop pattern for its children to give you full control over item states, such as hover effects.
import { RadialScrollGallery } from '@/components/radial-scroll-gallery';
const items = [
{ title: 'Item 1', color: 'bg-red-500' },
{ title: 'Item 2', color: 'bg-blue-500' },
{ title: 'Item 3', color: 'bg-green-500' },
{ title: 'Item 4', color: 'bg-yellow-500' },
{ title: 'Item 5', color: 'bg-purple-500' },
];
export default function Demo() {
return (
<div className='bg-black min-h-screen'>
<div className='h-screen flex items-center justify-center text-white'>
<h1>Scroll Down to Reveal</h1>
</div>
<RadialScrollGallery
baseRadius={600}
mobileRadius={300}
scrollDuration={3000}
visiblePercentage={50}
onItemSelect={(index) => console.log('Selected:', index)}
>
{(hoveredIndex) =>
items.map((item, index) => (
<div
key={index}
className={`w-48 h-64 rounded-xl p-4 flex items-center justify-center transition-all duration-300 ${
item.color
} ${
hoveredIndex === index
? 'scale-110 shadow-2xl ring-2 ring-white'
: 'opacity-80'
}`}
>
<span className='text-white font-bold text-xl'>{item.title}</span>
</div>
))
}
</RadialScrollGallery>
<div className='h-screen flex items-center justify-center text-white'>
<h1>Footer</h1>
</div>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | (hoveredIndex: number | null) => ReactNode[] | Required | Render function returning array of items. Receives hoveredIndex. |
| scrollDuration | number | 2500 | Vertical scroll pixels for one full rotation. |
| visiblePercentage | number | 45 | Percentage of circle visible above fold (0-100). |
| baseRadius | number | 550 | Radius of circle on desktop. |
| mobileRadius | number | 220 | Radius of circle on mobile (<768px). |
| startTrigger | string | "center center" | GSAP ScrollTrigger start position. |
| onItemSelect | (index: number) => void | - | Callback when item is selected. |
| direction | 'ltr' | 'rtl' | 'ltr' | Rotational direction. |
| disabled | boolean | false | Disables interactions. |