# Contact 03

> Centered contact panel whose bottom glow widens as the section scrolls into view, with a pill badge, a word-by-word headline reveal, two short paragraphs, and a two-column CTA grid: an email button with the address and a copy button beneath it and an outline call button that opens a WhatsApp chat. Ships composable parts.

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

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

## Install

```bash
npx shadcn@latest add https://hirael.com/r/contact-03.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`
- Registry items: `badge`, `button`

## Files

### `registry/hirael/bases/radix/blocks/contact-03/contact-03.tsx`

```tsx
// Contact 03 from Hirael <https://hirael.com/blocks/contact/contact-03>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { Check, Copy, Mail, Phone } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
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 SWAP =
  'animate-in fade-in zoom-in-95 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

const CONTACT = {
  email: 'hello@hirael.com',
  phone: '+971 50 000 0000',
};

const HEADLINE = 'Tell us what you are building';

const digits = (phone: string) => phone.replace(/\D/g, '');

const GLOW =
  'radial-gradient(100% 100% at 50% 0%, transparent 0%, transparent 55%, color-mix(in oklch, var(--primary) 18%, transparent) 82%, color-mix(in oklch, var(--primary) 40%, transparent) 100%)';

const ContactPanel = ({ className, children, ...props }: React.ComponentProps<'div'>) => {
  const ref = React.useRef<HTMLDivElement>(null);
  const glowRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const panel = ref.current;
    const glow = glowRef.current;
    if (!panel || !glow) return;

    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
    let frame = 0;

    const paint = () => {
      frame = 0;
      if (reduce.matches) {
        glow.style.transform = 'scale(1.3, 1.4)';

        return;
      }
      const rect = panel.getBoundingClientRect();
      const viewport = window.innerHeight;
      const progress = Math.min(1, Math.max(0, (viewport - rect.top) / (viewport + rect.height)));
      glow.style.transform = `scale(${1 + progress * 0.6}, ${1 + progress * 0.8})`;
    };

    const schedule = () => {
      if (!frame) frame = requestAnimationFrame(paint);
    };

    paint();
    window.addEventListener('scroll', schedule, { passive: true });
    window.addEventListener('resize', schedule);
    reduce.addEventListener('change', schedule);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', schedule);
      window.removeEventListener('resize', schedule);
      reduce.removeEventListener('change', schedule);
    };
  }, []);

  return (
    <div ref={ref} data-slot="contact-panel-outer">
      <div
        data-slot="contact-panel"
        className={cn(
          'relative isolate overflow-hidden rounded-3xl border border-border bg-card pt-16 pb-24 md:pt-20 md:pb-32',
          RISE,
          className,
        )}
        {...props}
      >
        <div
          ref={glowRef}
          aria-hidden
          data-slot="contact-panel-glow"
          style={{ backgroundImage: GLOW }}
          className="pointer-events-none absolute inset-0 -z-10 origin-top will-change-transform"
        />
        {children}
      </div>
    </div>
  );
};

const ContactBadge = ({ className, ...props }: React.ComponentProps<typeof Badge>) => {
  return (
    <div className="animate-in duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both zoom-in-95 fade-in motion-reduce:animate-none">
      <Badge data-slot="contact-badge" variant="outline" className={className} {...props} />
    </div>
  );
};

interface ContactTitleProps extends Omit<React.ComponentProps<'h2'>, 'children'> {
  children: string;
}

const ContactTitle = ({ children, className, ...props }: ContactTitleProps) => {
  const words = children.split(' ');
  const half = Math.floor(words.length / 2);

  return (
    <h2
      data-slot="contact-title"
      className={cn(
        'mx-auto max-w-3xl font-serif text-4xl leading-[1.04] font-medium tracking-tight text-balance sm:text-5xl',
        className,
      )}
      {...props}
    >
      {words.map((word, i) => (
        <span
          key={`${word}-${i}`}
          className={cn(
            'inline-block',
            i < words.length - 1 && 'me-[0.25em]',
            RISE,
            i < half ? 'text-muted-foreground' : 'text-foreground',
          )}
          style={{ animationDelay: `${60 + i * 40}ms` }}
        >
          {word}
        </span>
      ))}
    </h2>
  );
};

const ContactDescription = ({ className, ...props }: React.ComponentProps<'p'>) => {
  return (
    <p
      data-slot="contact-description"
      className={cn('mx-auto max-w-2xl text-base text-pretty text-muted-foreground sm:text-lg', className)}
      {...props}
    />
  );
};

interface ContactActionProps extends React.ComponentProps<'div'> {
  /** Shown under the button, e.g. the address or number it opens. */
  detail: React.ReactNode;
}

const ContactAction = ({ detail, className, children, ...props }: ContactActionProps) => {
  return (
    <div data-slot="contact-action" className={cn('flex flex-col items-center gap-2', className)} {...props}>
      {children}
      <div className="flex min-h-6 items-center gap-1 text-xs text-muted-foreground">{detail}</div>
    </div>
  );
};

interface ContactCopyProps extends Omit<React.ComponentProps<typeof Button>, 'value' | 'children'> {
  /** Text written to the clipboard. */
  value: string;
  /** Read by screen readers, e.g. "Copy email address". */
  label?: string;
}

const ContactCopy = ({ value, label = 'Copy to clipboard', className, ...props }: ContactCopyProps) => {
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  React.useEffect(() => () => clearTimeout(timer.current), []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
      setCopied(true);
      clearTimeout(timer.current);
      timer.current = setTimeout(() => setCopied(false), 1600);
    } catch {
      setCopied(false);
    }
  };

  return (
    <Button
      type="button"
      variant="ghost"
      size="xs"
      data-slot="contact-copy"
      data-state={copied ? 'copied' : 'idle'}
      aria-label={copied ? 'Copied' : label}
      onClick={copy}
      className={className}
      {...props}
    >
      <span key={copied ? 'copied' : 'idle'} className={cn(SWAP, 'inline-flex items-center gap-1')}>
        {copied ? <Check aria-hidden className="size-3" /> : <Copy aria-hidden className="size-3" />}
        {copied ? 'Copied' : 'Copy'}
      </span>
    </Button>
  );
};

const Contact03 = () => {
  return (
    <section data-slot="contact" className="bg-background py-20 sm:py-28">
      <div className="mx-auto max-w-[1480px] px-4">
        <ContactPanel>
          <div className="relative mx-auto flex max-w-4xl flex-col items-center gap-6 px-6 text-center">
            <ContactBadge>Contact</ContactBadge>

            <ContactTitle>{HEADLINE}</ContactTitle>

            <div className={cn('flex flex-col gap-3', RISE, 'delay-320')}>
              <ContactDescription>
                Send a short note about the product and where you are stuck. We read every message and reply within a
                working day.
              </ContactDescription>
              <ContactDescription className="text-sm sm:text-base">
                Prefer to talk? The number below opens a chat, no call needed.
              </ContactDescription>
            </div>

            <div className={cn('mt-4 grid w-full max-w-md grid-cols-1 gap-4 sm:grid-cols-2', RISE, 'delay-400')}>
              <ContactAction
                detail={
                  <>
                    <span>{CONTACT.email}</span>
                    <ContactCopy value={CONTACT.email} label="Copy email address" />
                  </>
                }
              >
                <Button asChild size="lg" className="w-full">
                  <a href={`mailto:${CONTACT.email}`}>
                    <Mail />
                    Send an email
                  </a>
                </Button>
              </ContactAction>
              <ContactAction detail={<span dir="ltr">{CONTACT.phone}</span>}>
                <Button asChild size="lg" variant="outline" className="w-full">
                  <a href={`https://wa.me/${digits(CONTACT.phone)}`} target="_blank" rel="noreferrer">
                    <Phone />
                    Call or chat
                  </a>
                </Button>
              </ContactAction>
            </div>
          </div>
        </ContactPanel>
      </div>
    </section>
  );
};

export { ContactPanel, ContactBadge, ContactTitle, ContactDescription, ContactAction, ContactCopy };

export default Contact03;
```
