# Process Card Reveal

> Numbered steps on a timeline whose connector fills before the next card slides and blurs into place — image one way, copy the other. Hover pauses the loop; clicking a step jumps there.

A shadcn/ui block from the [@tween-ui](https://tween-ui.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tween-ui/process-card-reveal).

- Page, with a live preview: https://shadcn.exchange/r/tween-ui/process-card-reveal
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tween-ui/process-card-reveal-light-4d4272306c361a11.png
- Registry item: https://tween-ui.vercel.app/r/process-card-reveal.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://tween-ui.vercel.app/r/process-card-reveal.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@gsap/react`, `gsap`

## Notes from the registry

Docs, props and examples: https://tween-ui.vercel.app/block/process-card-reveal
The sample content loads images from images.unsplash.com. Replace them with your own before shipping.

## Files

### `components/tweenui/process-card-reveal.tsx`

```tsx
'use client';

import { useRef, useState, type ComponentPropsWithoutRef } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { cn } from '@/lib/utils';

gsap.registerPlugin(useGSAP);

const prefersReducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

const PROCESS_ANIMATION = {
  STEP_WAIT: 1.2,
  LINE_DURATION: 1,
  OPEN_DUR: 1,
  CLOSE_DUR: 0.8,
  EASE: 'cubic-bezier(0.22, 1, 0.36, 1)',
  TEXT_STAGGER: 0.3,
  BLUR_IN: 'blur(3px)',
  BLUR_OUT: 'blur(0px)',
} as const;

const PROCESS_REVEAL_DUR = Math.max(
  PROCESS_ANIMATION.CLOSE_DUR,
  PROCESS_ANIMATION.OPEN_DUR + PROCESS_ANIMATION.TEXT_STAGGER
);

const overlayFigureStyle = { boxShadow: '0 1px 0 0 rgba(255, 255, 255, 0.4) inset' };

export interface ProcessStepDetail {
  title: string;
  description: string;
}

export interface ProcessStep {
  /** Stable id used as the React key. */
  id: string;
  /** Step heading shown in the card copy. */
  title: string;
  /** Supporting line under the title. */
  description: string;
  /** Photo URL for the card. */
  image: string;
  /** Alt text for the photo. */
  imageAlt: string;
  /** Optional overlay image sitting on the photo. */
  overlay?: string;
  /** Alt text for the overlay image. */
  overlayAlt?: string;
  /** Detail rows shown beside the photo on desktop. */
  details: ProcessStepDetail[];
}

export interface ProcessCardRevealProps extends Omit<ComponentPropsWithoutRef<'section'>, 'title'> {
  /** Process steps shown in the stacked cards. Defaults to a 4-step sample. */
  steps?: ProcessStep[];
}

const DEFAULT_STEPS: ProcessStep[] = [
  {
    id: 'connect',
    title: 'Connect apps',
    description:
      'Link your favorite tools in seconds and start building workflows without any setup complexity.',
    image: 'https://images.unsplash.com/photo-1551434678-e076c223a692?w=960&h=720&fit=crop',
    imageAlt: 'Team connecting apps on a laptop',
    details: [
      {
        title: 'One-Click Integrations',
        description: 'Connect popular apps instantly — no technical setup required.',
      },
      {
        title: 'Secure Connections',
        description: 'Your data stays protected with enterprise-grade security.',
      },
    ],
  },
  {
    id: 'build',
    title: 'Build flows',
    description:
      'Combine your connected apps into clear, repeatable automation in a few simple steps.',
    image: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=960&h=720&fit=crop',
    imageAlt: 'Building an automation flow on a dashboard',
    details: [
      {
        title: 'Drag And Drop Builder',
        description: 'Arrange triggers and actions visually with zero coding effort.',
      },
      {
        title: 'Reusable Templates',
        description: 'Start from prebuilt flows and customize them in minutes.',
      },
    ],
  },
  {
    id: 'test',
    title: 'Test and optimize',
    description:
      'Validate every step, monitor results, and improve your workflow performance over time.',
    image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=960&h=720&fit=crop',
    imageAlt: 'Analytics charts used to test and optimize a flow',
    details: [
      {
        title: 'Live Status Tracking',
        description: 'See exactly how each run performs and catch issues instantly.',
      },
      {
        title: 'Smart Insights',
        description: 'Use actionable analytics to refine every stage of your flow.',
      },
    ],
  },
  {
    id: 'scale',
    title: 'Scale with confidence',
    description:
      'Launch automation across your team while keeping speed, reliability, and security under control.',
    image: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=960&h=720&fit=crop',
    imageAlt: 'Team collaborating as they scale automation',
    details: [
      {
        title: 'Team Collaboration',
        description: 'Share workflows and permissions with clear ownership.',
      },
      {
        title: 'Enterprise Ready',
        description: 'Keep every process compliant and secure as you grow.',
      },
    ],
  },
];

type StepState = 'active' | 'completed' | 'inactive';

type TimelineBundle = {
  lines: HTMLElement[];
};

function isLineHorizontal(el: HTMLElement) {
  const axis = el.dataset.lineAxis;
  if (axis === 'horizontal') return true;
  if (axis === 'vertical') return false;
  return el.offsetWidth > el.offsetHeight;
}

function collect(items: Array<HTMLElement | null>) {
  return items.filter((item): item is HTMLElement => item !== null);
}

function initTimelineLines(timelines: TimelineBundle[]) {
  timelines.forEach(({ lines }) => {
    lines.forEach((line) => {
      if (isLineHorizontal(line)) gsap.set(line, { transformOrigin: 'left center', scaleX: 0 });
      else gsap.set(line, { transformOrigin: 'top center', scaleY: 0 });
    });
  });
}

function setTimelineLineProgress(timelines: TimelineBundle[], index: number) {
  timelines.forEach(({ lines }) => {
    lines.forEach((line, i) => {
      if (isLineHorizontal(line)) gsap.set(line, { scaleX: i < index ? 1 : 0 });
      else gsap.set(line, { scaleY: i < index ? 1 : 0 });
    });
  });
}

function animateTimelineLine(
  timelines: TimelineBundle[],
  lineIndex: number,
  timeline: gsap.core.Timeline
) {
  timelines.forEach(({ lines }) => {
    const line = lines[lineIndex];
    if (!line) return;

    if (isLineHorizontal(line))
      timeline.to(
        line,
        { scaleX: 1, duration: PROCESS_ANIMATION.LINE_DURATION, ease: 'sine.inOut' },
        0
      );
    else
      timeline.to(
        line,
        { scaleY: 1, duration: PROCESS_ANIMATION.LINE_DURATION, ease: 'sine.inOut' },
        0
      );
  });
}

function snapToCard({
  nextIndex,
  cards,
  images,
  contents,
}: {
  nextIndex: number;
  cards: HTMLElement[];
  images: HTMLElement[];
  contents: HTMLElement[];
}) {
  cards.forEach((card, i) => {
    gsap.set(card, {
      pointerEvents: i === nextIndex ? 'auto' : 'none',
      zIndex: i === nextIndex ? 2 : 0,
      overflow: 'hidden',
    });
  });
  images.forEach((image, i) => {
    gsap.set(image, {
      yPercent: 0,
      opacity: i === nextIndex ? 1 : 0,
      filter: PROCESS_ANIMATION.BLUR_OUT,
    });
  });
  contents.forEach((content, i) => {
    gsap.set(content, {
      yPercent: 0,
      opacity: i === nextIndex ? 1 : 0,
      filter: PROCESS_ANIMATION.BLUR_OUT,
    });
  });
}

function playCardSlide({
  current,
  next,
  currentImage,
  currentText,
  nextImage,
  nextText,
  reverse,
  onComplete,
}: {
  current: HTMLElement;
  next: HTMLElement;
  currentImage?: HTMLElement;
  currentText?: HTMLElement;
  nextImage?: HTMLElement;
  nextText?: HTMLElement;
  reverse: boolean;
  onComplete: () => void;
}) {
  const { CLOSE_DUR, OPEN_DUR, EASE, TEXT_STAGGER, BLUR_IN, BLUR_OUT } = PROCESS_ANIMATION;
  const tl = gsap.timeline({ onComplete });

  tl.set(next, { pointerEvents: 'auto', zIndex: 2, backgroundColor: 'transparent' }, 0);
  tl.set(current, { zIndex: 3, backgroundColor: 'transparent' }, 0);

  if (nextImage)
    tl.set(nextImage, { yPercent: reverse ? -100 : 100, opacity: 0, filter: BLUR_IN }, 0);
  if (nextText)
    tl.set(nextText, { yPercent: reverse ? 100 : -100, opacity: 0, filter: BLUR_IN }, 0);

  if (currentImage)
    tl.to(
      currentImage,
      {
        yPercent: reverse ? 100 : -100,
        opacity: 0,
        filter: BLUR_IN,
        duration: CLOSE_DUR,
        force3D: true,
        ease: EASE,
      },
      0
    );
  if (nextImage)
    tl.to(
      nextImage,
      { yPercent: 0, opacity: 1, filter: BLUR_OUT, duration: OPEN_DUR, force3D: true, ease: EASE },
      0
    );
  if (currentText)
    tl.to(
      currentText,
      {
        yPercent: reverse ? -100 : 100,
        opacity: 0,
        filter: BLUR_IN,
        duration: CLOSE_DUR,
        force3D: true,
        ease: EASE,
      },
      0
    );
  if (nextText)
    tl.to(
      nextText,
      { yPercent: 0, opacity: 1, filter: BLUR_OUT, duration: OPEN_DUR, force3D: true, ease: EASE },
      TEXT_STAGGER
    );

  tl.set(current, { pointerEvents: 'none', zIndex: 0, backgroundColor: '' }, PROCESS_REVEAL_DUR);
  tl.set(next, { backgroundColor: '' }, PROCESS_REVEAL_DUR);
  if (currentImage)
    tl.set(currentImage, { yPercent: 0, opacity: 0, filter: BLUR_OUT }, PROCESS_REVEAL_DUR);
  if (currentText)
    tl.set(currentText, { yPercent: 0, opacity: 0, filter: BLUR_OUT }, PROCESS_REVEAL_DUR);

  return tl;
}

export default function ProcessCardReveal({
  steps = DEFAULT_STEPS,
  className,
  ...props
}: ProcessCardRevealProps) {
  const cardCount = steps.length;
  const lineCount = Math.max(cardCount - 1, 0);

  const rootRef = useRef<HTMLElement>(null);
  const cardsRef = useRef<Array<HTMLElement | null>>([]);
  const imagesRef = useRef<Array<HTMLElement | null>>([]);
  const contentsRef = useRef<Array<HTMLElement | null>>([]);
  const horizontalLinesRef = useRef<Array<HTMLElement | null>>([]);
  const verticalLinesRef = useRef<Array<HTMLElement | null>>([]);

  const [activeIndex, setActiveIndex] = useState(0);
  const activeIndexRef = useRef(0);
  const busyRef = useRef(false);
  const pausedRef = useRef(false);
  const lineTlRef = useRef<gsap.core.Timeline | undefined>(undefined);
  const slideTlRef = useRef<gsap.core.Timeline | undefined>(undefined);
  const loopDelayRef = useRef<gsap.core.Tween | undefined>(undefined);

  const goToStepRef = useRef<(index: number) => void>(() => {});
  const pauseRef = useRef<() => void>(() => {});
  const resumeRef = useRef<() => void>(() => {});

  const getStepState = (index: number): StepState => {
    if (index === activeIndex) return 'active';
    if (index < activeIndex) return 'completed';
    return 'inactive';
  };

  useGSAP(
    (_context, contextSafe) => {
      if (!contextSafe) return;

      const cards = collect(cardsRef.current);
      const images = collect(imagesRef.current);
      const contents = collect(contentsRef.current);
      const timelines: TimelineBundle[] = [
        { lines: collect(horizontalLinesRef.current) },
        { lines: collect(verticalLinesRef.current) },
      ];

      if (
        cards.length !== cardCount ||
        images.length !== cardCount ||
        contents.length !== cardCount
      )
        return;

      const reduced = prefersReducedMotion();

      cards.forEach((card, index) => {
        gsap.set(card, {
          pointerEvents: index === 0 ? 'auto' : 'none',
          zIndex: index === 0 ? 2 : 0,
          overflow: 'hidden',
        });
      });
      images.forEach((image, index) => {
        gsap.set(image, {
          yPercent: 0,
          opacity: index === 0 ? 1 : 0,
          filter: PROCESS_ANIMATION.BLUR_OUT,
          willChange: 'transform, opacity, filter',
        });
      });
      contents.forEach((content, index) => {
        gsap.set(content, {
          yPercent: 0,
          opacity: index === 0 ? 1 : 0,
          filter: PROCESS_ANIMATION.BLUR_OUT,
          willChange: 'transform, opacity, filter',
        });
      });
      initTimelineLines(timelines);
      setTimelineLineProgress(timelines, 0);
      activeIndexRef.current = 0;
      setActiveIndex(0);
      busyRef.current = false;
      pausedRef.current = false;

      const slideTo = contextSafe((nextIndex: number, reverse = false) => {
        if (busyRef.current || nextIndex === activeIndexRef.current) return;

        const current = cards[activeIndexRef.current];
        const next = cards[nextIndex];
        if (!current || !next) return;

        const currentImage = images[activeIndexRef.current];
        const currentText = contents[activeIndexRef.current];
        const nextImage = images[nextIndex];
        const nextText = contents[nextIndex];

        activeIndexRef.current = nextIndex;
        setActiveIndex(nextIndex);
        setTimelineLineProgress(timelines, nextIndex);

        if (reduced) {
          snapToCard({ nextIndex, cards, images, contents });
          return;
        }

        busyRef.current = true;
        slideTlRef.current?.kill();
        slideTlRef.current = playCardSlide({
          current,
          next,
          currentImage,
          currentText,
          nextImage,
          nextText,
          reverse,
          onComplete: () => {
            busyRef.current = false;
          },
        });
      });

      const scheduleLoop = contextSafe(() => {
        if (reduced || pausedRef.current || cardCount < 2) return;
        loopDelayRef.current?.kill();
        loopDelayRef.current = gsap.delayedCall(PROCESS_ANIMATION.STEP_WAIT, () =>
          runLoopRef.current()
        );
      });

      const runLoop = contextSafe(() => {
        if (reduced || pausedRef.current || cardCount < 2) return;
        if (busyRef.current) {
          scheduleLoop();
          return;
        }

        const currentIndex = activeIndexRef.current;
        const nextIndex = (currentIndex + 1) % cardCount;
        const lineIndex = currentIndex % Math.max(cardCount - 1, 1);

        lineTlRef.current?.kill();
        lineTlRef.current = gsap.timeline({
          onComplete: () => {
            slideTo(nextIndex);
            scheduleLoop();
          },
        });
        animateTimelineLine(timelines, lineIndex, lineTlRef.current);
      });

      const runLoopRef = { current: runLoop };

      goToStepRef.current = contextSafe((index: number) => {
        if (index === activeIndexRef.current || busyRef.current) return;
        lineTlRef.current?.kill();
        loopDelayRef.current?.kill();
        slideTo(index, index < activeIndexRef.current);
        scheduleLoop();
      });

      pauseRef.current = contextSafe(() => {
        pausedRef.current = true;
        lineTlRef.current?.pause();
        loopDelayRef.current?.pause();
      });

      resumeRef.current = contextSafe(() => {
        pausedRef.current = false;
        loopDelayRef.current?.kill();
        if (lineTlRef.current?.paused()) {
          lineTlRef.current.resume();
          return;
        }
        if (!busyRef.current) runLoop();
        else scheduleLoop();
      });

      if (!reduced && cardCount > 1) scheduleLoop();

      return () => {
        loopDelayRef.current?.kill();
        lineTlRef.current?.kill();
        slideTlRef.current?.kill();
      };
    },
    { scope: rootRef, dependencies: [cardCount], revertOnUpdate: true }
  );

  const renderTimeline = (axis: 'horizontal' | 'vertical') => {
    const isHorizontal = axis === 'horizontal';
    const linesRef = isHorizontal ? horizontalLinesRef : verticalLinesRef;

    return (
      <div
        data-process-timeline={axis}
        className={cn(
          'flex items-center justify-center',
          isHorizontal
            ? 'w-full flex-row md:hidden'
            : 'hidden shrink-0 flex-col self-stretch md:flex'
        )}
      >
        {steps.map((step, index) => (
          <div
            key={`${axis}-${step.id}`}
            className={cn(
              'flex items-center',
              isHorizontal ? 'flex-row' : 'flex-col',
              index < lineCount && 'flex-1'
            )}
          >
            <button
              type="button"
              data-item={getStepState(index)}
              aria-label={`Go to step ${index + 1}: ${step.title}`}
              aria-current={getStepState(index) === 'active' ? 'step' : undefined}
              onClick={() => goToStepRef.current(index)}
              className={cn(
                'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border text-xs font-medium transition-all duration-400 ease-in-out motion-reduce:transition-none',
                'border-[#045f64]/20 text-[#045f64]/50 dark:border-[#045f64]/40 dark:text-[#9fd4d6]/80',
                'data-[item=active]:border-transparent data-[item=active]:bg-[#045f64] data-[item=active]:text-white',
                'data-[item=completed]:border-transparent data-[item=completed]:bg-[#045f64] data-[item=completed]:text-white',
                'focus-visible:ring-2 focus-visible:ring-[#045f64] focus-visible:ring-offset-2 focus-visible:outline-none dark:ring-offset-[#12161F]'
              )}
            >
              {String(index + 1).padStart(2, '0')}
            </button>
            {index < lineCount && (
              <div
                className={cn(
                  'relative bg-[#045f64]/15 dark:bg-[#045f64]/30',
                  isHorizontal ? 'mx-1 h-px min-w-8 flex-1' : 'mx-auto min-h-8 w-px flex-1'
                )}
              >
                <div
                  ref={(el) => {
                    linesRef.current[index] = el;
                  }}
                  data-line-axis={axis}
                  className={cn(
                    'absolute inset-0',
                    isHorizontal
                      ? 'rounded-r-full bg-linear-to-r from-transparent to-[#c6f56f]'
                      : 'rounded-b-full bg-linear-to-b from-transparent to-[#c6f56f]'
                  )}
                />
              </div>
            )}
          </div>
        ))}
      </div>
    );
  };

  return (
    <section
      ref={rootRef}
      data-process-card-reveal
      data-active-step={activeIndex}
      className={cn('w-full', className)}
      {...props}
    >
      <div className="mx-auto w-full max-w-[720px] px-3">
        <div
          className="relative flex flex-col items-stretch justify-center gap-y-4 md:flex-row md:items-stretch md:gap-x-5"
          onMouseEnter={() => pauseRef.current()}
          onMouseLeave={() => resumeRef.current()}
        >
          {renderTimeline('horizontal')}

          <div className="relative min-h-[340px] w-full overflow-hidden md:h-[280px] md:min-h-0 md:flex-1">
            {steps.map((step, index) => (
              <div
                key={step.id}
                ref={(el) => {
                  cardsRef.current[index] = el;
                }}
                data-process-card
                data-step-id={step.id}
                className="absolute inset-0 rounded-2xl bg-white p-1 dark:bg-[#12161F]"
              >
                <div className="flex h-full w-full flex-col items-stretch gap-y-3 md:flex-row md:gap-x-5">
                  <div className="w-full shrink-0 overflow-hidden rounded-xl md:w-[46%]">
                    <figure
                      ref={(el) => {
                        imagesRef.current[index] = el;
                      }}
                      className="relative h-48 w-full overflow-hidden rounded-xl md:h-full"
                    >
                      <img
                        src={step.image}
                        alt={step.imageAlt}
                        className="size-full rounded-xl object-cover"
                      />
                      <figure
                        className="absolute bottom-3 left-1/2 z-20 w-[calc(100%-24px)] -translate-x-1/2 overflow-hidden rounded-xl bg-white/15 backdrop-blur-[20px]"
                        style={overlayFigureStyle}
                      >
                        {step.overlay ? (
                          <img src={step.overlay} alt={step.overlayAlt ?? ''} />
                        ) : (
                          <div className="px-3 py-2">
                            <p className="text-xs font-medium text-white drop-shadow">
                              {step.title}
                            </p>
                          </div>
                        )}
                      </figure>
                    </figure>
                  </div>

                  <div className="relative min-w-0 flex-1 overflow-hidden py-2 md:py-3">
                    <div
                      ref={(el) => {
                        contentsRef.current[index] = el;
                      }}
                      className="h-fit w-full space-y-4 max-md:px-3 md:h-full md:justify-center"
                    >
                      <div className="space-y-1">
                        <h3 className="text-base font-medium text-[#12161F] md:text-lg dark:text-white">
                          {step.title}
                        </h3>
                        <p className="text-left text-sm text-[#045f64]/70 dark:text-[#9fd4d6]/80">
                          {step.description}
                        </p>
                      </div>
                      <div className="hidden space-y-3 md:block">
                        {step.details.map((detail) => (
                          <div key={detail.title} className="space-y-0.5">
                            <h4 className="text-sm font-medium text-[#12161F] dark:text-white">
                              {detail.title}
                            </h4>
                            <p className="text-left text-sm text-[#045f64]/70 dark:text-[#9fd4d6]/80">
                              {detail.description}
                            </p>
                          </div>
                        ))}
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            ))}
          </div>

          {renderTimeline('vertical')}
        </div>
      </div>
    </section>
  );
}
```
