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).

Workflow

The Process

Scroll down to see the steps.

  • 01

    Discovery

    Requirements

  • 02

    Strategy

    Roadmap

  • 03

    Design

    Interface

  • 04

    Build

    Development

  • 05

    Launch

    Deployment

End of Workflow

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.

Portfolio

Work

↓ Scroll
  • Nebula
    Art

    Nebula

  • Decay
    Photo

    Decay

  • Oceanic
    Nature

    Oceanic

  • Neon
    Tech

    Neon

  • Desert
    Travel

    Desert

Footer

Installation

npx shadcn@latest add https://satisui.xyz/r/radial-scroll-gallery.json

Manual

1. Install Dependencies:

npm install gsap @gsap/react
# or
yarn add gsap @gsap/react
# or
pnpm add gsap @gsap/react

2. 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

PropTypeDefaultDescription
children(hoveredIndex: number | null) => ReactNode[]RequiredRender function returning array of items. Receives hoveredIndex.
scrollDurationnumber2500Vertical scroll pixels for one full rotation.
visiblePercentagenumber45Percentage of circle visible above fold (0-100).
baseRadiusnumber550Radius of circle on desktop.
mobileRadiusnumber220Radius of circle on mobile (<768px).
startTriggerstring"center center"GSAP ScrollTrigger start position.
onItemSelect(index: number) => void-Callback when item is selected.
direction'ltr' | 'rtl''ltr'Rotational direction.
disabledbooleanfalseDisables interactions.