# Footer 04

> Four-column footer lit by slow light rays falling from the top edge: brand mark and blurb, a contact column with WhatsApp and email links, a location column, and a subscribe form whose placeholder cycles through prompts. Columns and links rise in with staggered motion; a divided copyright row closes it. Rays hold still under reduced-motion.

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

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

## Install

```bash
npx shadcn@latest add https://hirael.com/r/footer-04.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`, `input`, `separator`

## Files

### `registry/hirael/bases/radix/blocks/footer-04/footer-04.tsx`

```tsx
// Footer 04 from Hirael <https://hirael.com/blocks/footer/footer-04>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { ArrowRight, Check } from 'lucide-react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { FooterBeams } from './footer-04-beams';

const BRAND = {
  name: 'Hirael',
  blurb: "Components, blocks and full pages that shadcn/ui doesn't ship. Install the source, keep the source.",
  copyright: '© 2026 Hirael. All rights reserved.',
};

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

const PLACEHOLDERS = ['you@hirael.com', 'Get release notes by email', 'One email per release'];

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const EASE = [0.22, 1, 0.36, 1] as const;

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

const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';

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

interface BrandMarkProps {
  className?: string;
}

const BrandMark = ({ className }: BrandMarkProps) => {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={cn('size-6 text-primary', className)}>
      <path d="M2.3 12h2.4v10.95h6.2V14.6h4.6v8.35h6.2V12h-2.4V1.05h-6.2V9.4H8.5V1.05H2.3Z" />
    </svg>
  );
};

interface RevealProps extends React.ComponentProps<'div'> {
  /** Milliseconds to wait before the reveal starts. */
  delay?: number;
}

const Reveal = ({ delay = 0, className, style, ...props }: RevealProps) => {
  return (
    <div
      className={cn(
        'animate-in duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both fade-in slide-in-from-bottom-4 motion-reduce:animate-none',
        className,
      )}
      style={{ animationDelay: `${delay}ms`, ...style }}
      {...props}
    />
  );
};

interface FooterColumnProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  title: string;
  children?: React.ReactNode;
  /** Milliseconds to wait before the column reveals. */
  delay?: number;
}

const FooterColumn = ({ title, delay = 0, className, children, ...props }: FooterColumnProps) => {
  return (
    <Reveal data-slot="footer-column" delay={delay} className={cn('flex flex-col gap-4', className)} {...props}>
      <h3 data-slot="footer-column-title" className="text-xs text-muted-foreground uppercase">
        {title}
      </h3>
      {children}
    </Reveal>
  );
};

interface FooterLinksProps extends React.ComponentProps<'ul'> {
  links: readonly { label: string; href: string; external?: boolean; ltr?: boolean }[];
  /** Milliseconds to wait before the first link reveals. */
  delay?: number;
}

const FooterLinks = ({ links, delay = 0, className, ...props }: FooterLinksProps) => {
  return (
    <ul data-slot="footer-links" className={cn('flex flex-col gap-2', className)} {...props}>
      {links.map((link, i) => (
        <li
          key={link.label}
          className="animate-in duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both fade-in slide-in-from-bottom-2 motion-reduce:animate-none"
          style={{ animationDelay: `${delay + i * 50}ms` }}
        >
          <a
            href={link.href}
            dir={link.ltr ? 'ltr' : undefined}
            target={link.external ? '_blank' : undefined}
            rel={link.external ? 'noreferrer' : undefined}
            className={cn(
              'text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground',
              focusRing,
            )}
          >
            {link.label}
          </a>
        </li>
      ))}
    </ul>
  );
};

export interface FooterSubscribeProps extends Omit<React.ComponentProps<'form'>, 'onSubmit'> {
  /** Placeholders to cycle through, one every `interval` ms. */
  placeholders?: readonly string[];
  interval?: number;
  /** Called with a valid email on submit. The preview never sends it anywhere. */
  onSubscribe?: (email: string) => void;
}

const FooterSubscribe = ({
  placeholders = PLACEHOLDERS,
  interval = 3000,
  onSubscribe,
  className,
  ...props
}: FooterSubscribeProps) => {
  const reduce = useReducedMotion();
  const id = React.useId();
  const errorId = `${id}-error`;
  const [email, setEmail] = React.useState('');
  const [index, setIndex] = React.useState(0);
  const [error, setError] = React.useState<string | null>(null);
  const [subscribed, setSubscribed] = React.useState<string | null>(null);

  React.useEffect(() => {
    if (placeholders.length < 2 || reduce) return;
    let timer: ReturnType<typeof setInterval> | null = null;

    const start = () => {
      timer = setInterval(() => {
        setIndex((i) => (i + 1) % placeholders.length);
      }, interval);
    };
    const stop = () => {
      if (timer) clearInterval(timer);
      timer = null;
    };
    const onVisibility = () => {
      stop();
      if (document.visibilityState === 'visible') start();
    };

    start();
    document.addEventListener('visibilitychange', onVisibility);

    return () => {
      stop();
      document.removeEventListener('visibilitychange', onVisibility);
    };
  }, [placeholders, interval, reduce]);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const value = email.trim();
    if (!value) {
      setError('Enter your email address.');

      return;
    }
    if (!EMAIL_PATTERN.test(value)) {
      setError("That doesn't look like an email address.");

      return;
    }
    setError(null);
    onSubscribe?.(value);
    setSubscribed(value);
    setEmail('');
  };

  if (subscribed) {
    return (
      <div
        role="status"
        data-slot="footer-subscribe-done"
        className={cn(SWAP, 'flex min-h-10 flex-col justify-center gap-1')}
      >
        <p className="inline-flex items-center gap-2 text-sm font-medium text-foreground">
          <Check aria-hidden className="size-4 text-muted-foreground" />
          You&apos;re on the list
        </p>
        <p className="text-sm text-muted-foreground">
          Release notes go to <span className="text-foreground">{subscribed}</span>.{' '}
          <button
            type="button"
            onClick={() => setSubscribed(null)}
            className={cn('underline underline-offset-4 transition-colors hover:text-foreground', focusRing)}
          >
            Use another email
          </button>
        </p>
      </div>
    );
  }

  return (
    <form
      data-slot="footer-subscribe"
      noValidate
      onSubmit={handleSubmit}
      className={cn('flex w-full flex-col gap-2', SWAP, className)}
      {...props}
    >
      <div className="flex w-full gap-2">
        <div className="relative flex-1">
          <label htmlFor={id} className="sr-only">
            Email address
          </label>
          <Input
            id={id}
            type="email"
            autoComplete="email"
            value={email}
            onChange={(e) => {
              setEmail(e.target.value);
              if (error) setError(null);
            }}
            placeholder=""
            aria-invalid={error ? true : undefined}
            aria-describedby={error ? errorId : undefined}
            className="h-10"
          />
          {email === '' ? (
            <div
              aria-hidden
              data-slot="footer-subscribe-placeholder"
              className="pointer-events-none absolute inset-y-0 start-3 end-3 flex items-center overflow-hidden text-sm text-muted-foreground"
            >
              <AnimatePresence mode="wait" initial={false}>
                <motion.span
                  key={placeholders[index]}
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? undefined : { opacity: 0, y: -8 }}
                  transition={{ duration: 0.25, ease: EASE }}
                  className="block truncate"
                >
                  {placeholders[index]}
                </motion.span>
              </AnimatePresence>
            </div>
          ) : null}
        </div>
        <Button type="submit" size="icon-lg" aria-label="Subscribe" className="shrink-0">
          <ArrowRight className="rtl:rotate-180" />
        </Button>
      </div>
      {error ? (
        <p id={errorId} role="alert" className={cn(SWAP, 'ps-4 text-sm text-destructive')}>
          {error}
        </p>
      ) : null}
    </form>
  );
};

const Footer04 = () => {
  return (
    <footer data-slot="footer" className="relative w-full text-foreground">
      <FooterBeams>
        <div className="mx-auto max-w-[1480px] px-4 py-12 md:py-16">
          <div className="grid grid-cols-2 gap-x-6 gap-y-10 lg:grid-cols-4">
            <Reveal data-slot="footer-brand" className="col-span-2 flex flex-col gap-4 lg:col-span-1">
              <a
                href="#"
                className={cn(
                  'inline-flex w-fit items-center gap-2 text-base font-semibold tracking-tight text-foreground',
                  focusRing,
                )}
              >
                <BrandMark />
                {BRAND.name}
              </a>
              <p className="max-w-xs text-sm text-muted-foreground">{BRAND.blurb}</p>
            </Reveal>

            <FooterColumn title="Contact" delay={60}>
              <FooterLinks
                delay={120}
                links={[
                  {
                    label: CONTACT.phone,
                    href: `https://wa.me/${digits(CONTACT.phone)}`,
                    external: true,
                    ltr: true,
                  },
                  { label: CONTACT.email, href: `mailto:${CONTACT.email}` },
                ]}
              />
            </FooterColumn>

            <FooterColumn title="Location" delay={120}>
              <p className="text-sm text-muted-foreground">{CONTACT.location}</p>
            </FooterColumn>

            <FooterColumn title="Release notes" delay={180} className="col-span-2 lg:col-span-1">
              <FooterSubscribe />
              <p className="text-sm text-muted-foreground">One email per release. Unsubscribe from any of them.</p>
            </FooterColumn>
          </div>

          <Separator className="my-8" />

          <Reveal
            data-slot="footer-bottom"
            delay={240}
            className="flex flex-col items-center justify-between gap-3 text-center sm:flex-row sm:text-start"
          >
            <p className="text-sm text-muted-foreground">{BRAND.copyright}</p>
            <a
              href="https://github.com/MohammadShehadeh/hirael"
              target="_blank"
              rel="noreferrer"
              className={cn(
                'text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground',
                focusRing,
              )}
            >
              GitHub
            </a>
          </Reveal>
        </div>
      </FooterBeams>
    </footer>
  );
};

export { FooterBeams, FooterColumn, FooterLinks, FooterSubscribe };

export default Footer04;
```

### `registry/hirael/bases/radix/blocks/footer-04/footer-04-beams.tsx`

```tsx
// Footer 04 from Hirael <https://hirael.com/blocks/footer/footer-04>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { motion, useReducedMotion } from 'motion/react';

import { cn } from '@/lib/utils';

interface RayConfig {
  left: string;
  rotation: number;
  width: number;
  duration: number;
  delay: number;
  swayDuration: number;
  swayDelay: number;
  blur: number;
  strongSway: boolean;
}

// The golden-ratio step keeps neighbouring rays from pulsing in lockstep.
const rayConfig = (index: number, total: number): RayConfig => {
  const progress = index / Math.max(total - 1, 1);
  const variation = (index * 0.618) % 1;

  return {
    left: `${2 + progress * 96}%`,
    rotation: 28 - progress * 56,
    width: 40 + variation * 25,
    duration: 6 + variation * 5,
    delay: -variation * 10,
    swayDuration: 12 + variation * 9,
    swayDelay: -variation * 10,
    blur: 24 + variation * 9,
    strongSway: index % 2 === 0,
  };
};

interface LightRayProps extends RayConfig {
  opacity: number;
  speed: number;
  length: string;
  reduce: boolean;
}

const LightRay = React.memo(
  ({
    left,
    rotation,
    width,
    duration,
    delay,
    swayDuration,
    swayDelay,
    blur,
    strongSway,
    opacity,
    speed,
    length,
    reduce,
  }: LightRayProps) => {
    const rotate = (deg: number) => `translateX(-50%) rotate(${deg}deg)`;

    return (
      <motion.div
        data-slot="footer-beam"
        aria-hidden
        className="pointer-events-none absolute -top-[5%] origin-top mix-blend-screen"
        style={{
          left,
          width,
          height: length,
          filter: `blur(${blur}px)`,
          transform: rotate(rotation),
          opacity: reduce ? opacity : undefined,
          background: `linear-gradient(to bottom, color-mix(in oklch, var(--primary) ${Math.round(
            opacity * 100,
          )}%, transparent), transparent)`,
          willChange: reduce ? undefined : 'opacity, transform',
        }}
        initial={reduce ? false : { opacity: opacity * 0.4 }}
        animate={
          reduce
            ? undefined
            : {
                opacity: [opacity * 0.4, opacity, opacity * 0.4],
                transform: [rotate(rotation), rotate(rotation + (strongSway ? 1 : 0.5)), rotate(rotation)],
              }
        }
        transition={
          reduce
            ? undefined
            : {
                opacity: {
                  duration: duration / speed,
                  delay: delay / speed,
                  repeat: Infinity,
                  ease: 'easeInOut',
                },
                transform: {
                  duration: swayDuration / speed,
                  delay: swayDelay / speed,
                  repeat: Infinity,
                  ease: 'easeInOut',
                },
              }
        }
      />
    );
  },
);
LightRay.displayName = 'LightRay';

export interface FooterBeamsProps extends React.ComponentProps<'div'> {
  /** Number of rays fanned across the top edge. */
  rayCount?: number;
  /** Peak opacity of a ray, 0 to 1. */
  rayOpacity?: number;
  /** Global speed multiplier. Higher is faster. */
  raySpeed?: number;
  /** Ray length, any CSS length. */
  rayLength?: string;
}

const FooterBeams = ({
  rayCount = 12,
  rayOpacity = 0.35,
  raySpeed = 1,
  rayLength = '45vh',
  className,
  children,
  ...props
}: FooterBeamsProps) => {
  const reduce = useReducedMotion() ?? false;
  const rays = React.useMemo(() => Array.from({ length: rayCount }, (_, i) => rayConfig(i, rayCount)), [rayCount]);

  return (
    <div data-slot="footer-beams" className={cn('relative overflow-hidden bg-background', className)} {...props}>
      <div aria-hidden data-slot="footer-beams-layer" className="pointer-events-none absolute inset-0 overflow-hidden">
        {rays.map((ray) => (
          <LightRay key={ray.left} {...ray} opacity={rayOpacity} speed={raySpeed} length={rayLength} reduce={reduce} />
        ))}
      </div>
      <div data-slot="footer-beams-content" className="relative">
        {children}
      </div>
    </div>
  );
};

export { FooterBeams };
```
