# CTA Photo Headline

> A call-to-action where avatars pop in above a heading with two photos tucked into the copy — a pill that grows from the left, then a tilted square that drops into place.

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

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

## Install

```bash
npx shadcn@latest add https://tween-ui.vercel.app/r/cta-photo-headline.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`
- Registry items: `https://tween-ui.vercel.app/r/slide-arrow-button.json`

## Notes from the registry

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

## Files

### `components/tweenui/cta-photo-headline.tsx`

```tsx
'use client';

import { useRef, type ComponentPropsWithoutRef } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { cn } from '@/lib/utils';
import SlideArrowButton from './slide-arrow-button';

gsap.registerPlugin(useGSAP);

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

export interface CtaPhotoHeadlineAvatar {
  src: string;
  alt: string;
}

export interface CtaPhotoHeadlineImage {
  src: string;
  alt?: string;
}

export interface CtaPhotoHeadlineProps extends ComponentPropsWithoutRef<'section'> {
  /** Faces in the row above the heading. */
  avatars?: CtaPhotoHeadlineAvatar[];
  /** Landscape photo tucked into the first headline line. */
  inlineImage?: CtaPhotoHeadlineImage;
  /** Tilted photo on the second headline line. */
  accentImage?: CtaPhotoHeadlineImage;
  /** First headline fragment, before the pill photo. */
  lead?: string;
  /** Headline fragment after the pill photo. */
  mid?: string;
  /** Second-line headline, after the tilted photo. */
  end?: string;
  /** Supporting line under the heading. */
  description?: string;
  /** Label for the slide-arrow CTA. */
  ctaLabel?: string;
  /** Click handler for the CTA button. */
  onCtaClick?: () => void;
}

const DEFAULT_AVATARS: CtaPhotoHeadlineAvatar[] = [
  {
    src: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=112&h=112&fit=crop&crop=faces',
    alt: 'Motion designer',
  },
  {
    src: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=112&h=112&fit=crop&crop=faces',
    alt: 'Frontend engineer',
  },
  {
    src: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=112&h=112&fit=crop&crop=faces',
    alt: 'Product designer',
  },
  {
    src: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=112&h=112&fit=crop&crop=faces',
    alt: 'AI engineer',
  },
];

const DEFAULT_INLINE_IMAGE: CtaPhotoHeadlineImage = {
  src: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=264&h=156&fit=crop',
  alt: 'AI interface collage',
};

const DEFAULT_ACCENT_IMAGE: CtaPhotoHeadlineImage = {
  src: 'https://images.unsplash.com/photo-1617791160505-6f00504e3519?w=144&h=144&fit=crop',
  alt: 'Abstract 3D motion form',
};

export default function CtaPhotoHeadline({
  avatars = DEFAULT_AVATARS,
  inlineImage = DEFAULT_INLINE_IMAGE,
  accentImage = DEFAULT_ACCENT_IMAGE,
  lead = 'Turn static',
  mid = 'into motion',
  end = 'with Tween UI',
  description = 'Copy-paste GSAP blocks for React. Themed, reduced-motion ready, and built to drop into any stack.',
  ctaLabel = 'Browse the blocks',
  onCtaClick,
  className,
  ...props
}: CtaPhotoHeadlineProps) {
  const rootRef = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;

      const faces = gsap.utils.toArray<HTMLElement>('[data-cta-avatar]', root);
      const inlineEl = root.querySelector<HTMLElement>('[data-cta-inline-image]');
      const accentEl = root.querySelector<HTMLElement>('[data-cta-accent-image]');
      const targets = [...faces, inlineEl, accentEl].filter(Boolean);

      if (prefersReducedMotion()) {
        gsap.set(targets, {
          opacity: 1,
          x: 0,
          y: 0,
          scale: 1,
          scaleX: 1,
          rotation: 0,
          filter: 'none',
        });
        if (accentEl) gsap.set(accentEl, { rotation: -20 });
        return;
      }

      gsap.fromTo(
        faces,
        { opacity: 0, scale: 0, x: -40, filter: 'blur(5px)' },
        {
          opacity: 1,
          scale: 1,
          x: 0,
          filter: 'blur(0px)',
          duration: 1.5,
          stagger: 0.1,
          ease: 'elastic.out(1, 0.7)',
        }
      );

      if (inlineEl) {
        gsap.fromTo(
          inlineEl,
          { scaleX: 0, autoAlpha: 0, transformOrigin: 'left center' },
          {
            scaleX: 1,
            autoAlpha: 1,
            duration: 1.7,
            delay: 0.3,
            ease: 'power3.out',
          }
        );
      }

      if (accentEl) {
        gsap.fromTo(
          accentEl,
          { y: 80, scale: 0, autoAlpha: 0, rotation: 40, transformOrigin: 'center center' },
          {
            y: 0,
            scale: 1,
            autoAlpha: 1,
            rotation: -20,
            duration: 1.3,
            delay: 0.2,
            ease: 'power2.out',
          }
        );
      }
    },
    { scope: rootRef }
  );

  return (
    <section
      {...props}
      ref={rootRef}
      data-cta-photo-headline
      className={cn('w-full overflow-hidden py-16 md:py-20', className)}
    >
      <div className="mx-auto flex w-full max-w-[720px] flex-col items-center gap-10 px-4">
        <div className="flex items-center justify-center gap-2">
          {avatars.map((avatar) => (
            <figure
              key={avatar.src}
              data-cta-avatar
              className="size-14 shrink-0 overflow-hidden rounded-full opacity-0 motion-reduce:opacity-100"
            >
              <img src={avatar.src} alt={avatar.alt} className="size-full object-cover" />
            </figure>
          ))}
        </div>

        <div className="space-y-6 text-center">
          <h2 className="text-2xl leading-[1.15] font-medium tracking-tight text-[#12161F] md:text-3xl lg:text-4xl dark:text-white">
            <span className="inline-flex flex-wrap items-center justify-center gap-x-2 gap-y-2">
              <span>{lead}</span>
              <span
                data-cta-inline-image
                className="inline-block h-[52px] w-[88px] origin-left overflow-hidden rounded-full align-middle opacity-0 motion-reduce:opacity-100"
              >
                <img
                  src={inlineImage.src}
                  alt={inlineImage.alt ?? ''}
                  aria-hidden={!inlineImage.alt}
                  className="size-full object-cover"
                />
              </span>
              <span>{mid}</span>
            </span>
            <span className="mt-2 flex items-center justify-center gap-x-4">
              <span
                data-cta-accent-image
                className="inline-block size-12 overflow-hidden rounded-lg align-middle opacity-0 motion-reduce:-rotate-[20deg] motion-reduce:opacity-100"
              >
                <img
                  src={accentImage.src}
                  alt={accentImage.alt ?? ''}
                  aria-hidden={!accentImage.alt}
                  className="size-full object-cover"
                />
              </span>
              <span>{end}</span>
            </span>
          </h2>
          <p className="mx-auto max-w-[320px] text-sm leading-relaxed text-[#045f64]/70 dark:text-[#9fd4d6]/80">
            {description}
          </p>
        </div>

        <SlideArrowButton onClick={onCtaClick}>{ctaLabel}</SlideArrowButton>
      </div>
    </section>
  );
}
```
