# CTA 05

> Contact CTA panel with a scroll-reactive radial glow, an eyebrow badge, a word-by-word reveal headline, and two contact actions (email and call) each pairing a button with its detail line.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/cta-05).

- Page, with a live preview: https://shadcn.exchange/r/hirael/cta-05
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/cta-05-light-aae01445ec8883cb.png
- Registry item: https://hirael.com/r/cta-05.json
- Categories: blocks, cta
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/cta-05.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: `lucide-react`, `motion`
- Registry items: `button`, `https://hirael.com/r/copy-button.json`

## Files

### `registry/hirael/bases/radix/blocks/cta-05/cta-05.tsx`

```tsx
// CTA 05 from Hirael <https://hirael.com/blocks/cta/cta-05>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { Mail, Phone } from 'lucide-react';
import { motion, useReducedMotion, useScroll, useTransform } from 'motion/react';

import { cn } from '@/lib/utils';
import { CopyButton } from '@/registry/hirael/bases/radix/components/copy-button';
import { Button } from '@/registry/hirael/bases/radix/ui/button';

const RISE =
  'animate-in fade-in slide-in-from-bottom-4 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

const stagger = (index: number, step = 70, offset = 0): React.CSSProperties => ({
  animationDelay: `${offset + index * step}ms`,
});

const email = 'projects@hirael.com';
const phone = '+1 (555) 012 3456';

const glow = (w: number, h: number) =>
  `radial-gradient(${w}% ${h}% at 50% 0%, transparent 0%, transparent 55%, color-mix(in oklab, var(--primary) 60%, transparent) 80%, color-mix(in oklab, var(--primary) 18%, var(--card)) 100%)`;

const STATIC_GLOW = glow(120, 135);

const Cta05 = () => {
  const reduced = useReducedMotion();
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ['end start', 'start end'],
  });

  // Below 100% the ellipse no longer reaches the panel edges and the rim
  // disappears, so the whole scroll range maps onto the visible band.
  const gradientWidth = useTransform(scrollYProgress, [0, 1], [100, 140]);
  const gradientHeight = useTransform(scrollYProgress, [0, 1], [110, 160]);
  const scrollGlow = useTransform([gradientWidth, gradientHeight], (values) => {
    const [width = 120, height = 135] = values as number[];

    return glow(Math.round(width), Math.round(height));
  });

  return (
    <section data-slot="cta" className="bg-background py-12 md:py-16">
      <div ref={containerRef} className="mx-auto w-full max-w-[1480px] px-4">
        <div
          data-slot="cta-panel"
          className={cn(
            RISE,
            'relative overflow-hidden rounded-3xl border border-border bg-card pt-16 pb-28 md:pt-20 md:pb-36',
          )}
        >
          <motion.div
            aria-hidden
            data-slot="cta-glow"
            style={{ background: reduced ? STATIC_GLOW : scrollGlow }}
            className="pointer-events-none absolute inset-0 opacity-60 dark:opacity-100"
          />
          <div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center px-6 text-center">
            <span
              data-slot="cta-badge"
              style={stagger(1)}
              className={cn(
                RISE,
                'inline-flex w-fit items-center gap-2 rounded-full border border-border bg-background/60 px-4 py-1.5 text-xs text-muted-foreground uppercase backdrop-blur-sm',
              )}
            >
              Get in touch
            </span>

            <AnimatedTitle data-slot="cta-title" text="Let's talk about your next project" className="mt-5" />

            <p
              data-slot="cta-copy"
              style={stagger(0, 0, 360)}
              className={cn(RISE, 'mt-4 max-w-2xl text-base leading-relaxed text-muted-foreground md:text-lg')}
            >
              Tell us what you’re building and where it’s stuck. We read every message and usually reply within a day.
            </p>

            <div
              data-slot="cta-actions"
              style={stagger(0, 0, 420)}
              className={cn(RISE, 'mt-10 grid w-full max-w-md grid-cols-1 gap-5 md:grid-cols-2')}
            >
              <div className="flex flex-col items-center gap-2">
                <Button asChild size="lg" className="w-full">
                  <a href={`mailto:${email}`}>
                    <Mail className="size-4" />
                    Send email
                  </a>
                </Button>
                <CopyButton value={email} className="max-w-full">
                  {email}
                </CopyButton>
              </div>
              <div className="flex flex-col items-center gap-2">
                <Button asChild size="lg" variant="outline" className="w-full">
                  <a href={`tel:${phone.replace(/[^\d+]/g, '')}`}>
                    <Phone className="size-4" />
                    Call us
                  </a>
                </Button>
                <p dir="ltr" className="py-1.5 text-sm font-medium text-muted-foreground tabular-nums">
                  {phone}
                </p>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
};

const AnimatedTitle = ({ text, className, ...props }: React.ComponentProps<'h2'> & { text: string }) => {
  const words = text.split(' ');

  return (
    <h2
      className={cn(
        'mx-auto max-w-3xl font-serif text-3xl leading-tight font-medium tracking-tight text-foreground md:text-5xl',
        className,
      )}
      {...props}
    >
      {words.map((word, i) => (
        <span key={i} className={cn('me-2 inline-block', RISE)} style={stagger(i, 40, 120)}>
          {word}
        </span>
      ))}
    </h2>
  );
};

export default Cta05;
```
