Web InnoventixFreeCode

Columns Footer

Original · free

multi-column footer

byWeb InnoventixReact + Tailwind
footxcolumnsfooters
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/footx-columns.json
footx-columns.tsx
"use client";

import { useId, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";

type FooterLink = { label: string; href: string; badge?: string };
type FooterColumn = { id: string; title: string; links: FooterLink[] };
type Social = { label: string; href: string; icon: ReactNode };
type Region = { value: string; label: string; currency: string };

const COLUMNS: FooterColumn[] = [
  {
    id: "product",
    title: "Product",
    links: [
      { label: "Status pages", href: "#" },
      { label: "Uptime checks", href: "#" },
      { label: "Incident timeline", href: "#" },
      { label: "On-call schedules", href: "#" },
      { label: "Integrations", href: "#" },
      { label: "Changelog", href: "#", badge: "New" },
    ],
  },
  {
    id: "developers",
    title: "Developers",
    links: [
      { label: "Documentation", href: "#" },
      { label: "API reference", href: "#" },
      { label: "Webhooks", href: "#" },
      { label: "CLI & Terraform", href: "#" },
      { label: "Open source", href: "#" },
      { label: "Community", href: "#" },
    ],
  },
  {
    id: "company",
    title: "Company",
    links: [
      { label: "About us", href: "#" },
      { label: "Careers", href: "#", badge: "9" },
      { label: "Customers", href: "#" },
      { label: "Blog", href: "#" },
      { label: "Security", href: "#" },
      { label: "Contact", href: "#" },
    ],
  },
  {
    id: "legal",
    title: "Legal",
    links: [
      { label: "Terms of service", href: "#" },
      { label: "Privacy policy", href: "#" },
      { label: "Data processing", href: "#" },
      { label: "Subprocessors", href: "#" },
      { label: "SLA", href: "#" },
      { label: "Cookie settings", href: "#" },
    ],
  },
];

const REGIONS: Region[] = [
  { value: "us", label: "United States", currency: "USD $" },
  { value: "uk", label: "United Kingdom", currency: "GBP £" },
  { value: "eu", label: "Eurozone", currency: "EUR €" },
  { value: "in", label: "India", currency: "INR ₹" },
  { value: "au", label: "Australia", currency: "AUD $" },
];

function GitHubIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-5 w-5">
      <path d="M12 .5A11.5 11.5 0 0 0 .5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.06-.72.08-.71.08-.71 1.17.08 1.79 1.2 1.79 1.2 1.04 1.79 2.73 1.27 3.4.97.1-.76.4-1.27.74-1.56-2.56-.29-5.26-1.28-5.26-5.7 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.84 1.19 3.1 0 4.43-2.7 5.4-5.28 5.69.42.36.79 1.06.79 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12 11.5 11.5 0 0 0 12 .5Z" />
    </svg>
  );
}

function XIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-[18px] w-[18px]">
      <path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24h-6.65l-5.21-6.82-5.97 6.82H1.68l7.73-8.84L1.25 2.25h6.82l4.71 6.23 5.46-6.23Zm-1.16 17.52h1.83L7.02 4.13H5.05L17.08 19.77Z" />
    </svg>
  );
}

function LinkedInIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-5 w-5">
      <path d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28ZM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13ZM7.12 20.45H3.55V9h3.57v11.45ZM22.22 0H1.77C.8 0 0 .78 0 1.75v20.5C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.75V1.75C24 .78 23.2 0 22.22 0Z" />
    </svg>
  );
}

function YouTubeIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-5 w-5">
      <path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.51A3.02 3.02 0 0 0 .5 6.2C0 8.09 0 12 0 12s0 3.91.5 5.8a3.02 3.02 0 0 0 2.12 2.14c1.88.51 9.38.51 9.38.51s7.5 0 9.38-.51a3.02 3.02 0 0 0 2.12-2.14c.5-1.89.5-5.8.5-5.8s0-3.91-.5-5.8ZM9.6 15.6V8.4l6.25 3.6L9.6 15.6Z" />
    </svg>
  );
}

function RssIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-[18px] w-[18px]">
      <path d="M4 11a9 9 0 0 1 9 9h2.5A11.5 11.5 0 0 0 4 8.5V11Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm0-10a15 15 0 0 1 15 15h2.5A17.5 17.5 0 0 0 4 3.5V6Z" />
    </svg>
  );
}

const SOCIALS: Social[] = [
  { label: "GitHub", href: "https://github.com", icon: <GitHubIcon /> },
  { label: "X (Twitter)", href: "https://x.com", icon: <XIcon /> },
  { label: "LinkedIn", href: "https://linkedin.com", icon: <LinkedInIcon /> },
  { label: "YouTube", href: "https://youtube.com", icon: <YouTubeIcon /> },
  { label: "RSS feed", href: "#", icon: <RssIcon /> },
];

const FOCUS =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

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

export default function FootxColumns(): ReactNode {
  const reduce = useReducedMotion();
  const uid = useId();

  const [email, setEmail] = useState("");
  const [status, setStatus] = useState<"idle" | "error" | "success">("idle");
  const [open, setOpen] = useState<Record<string, boolean>>({});
  const [region, setRegion] = useState<string>("us");

  const emailId = `${uid}-email`;
  const msgId = `${uid}-msg`;

  const activeRegion = REGIONS.find((r) => r.value === region) ?? REGIONS[0];

  function handleSubmit(e: FormEvent<HTMLFormElement>): void {
    e.preventDefault();
    if (!EMAIL_RE.test(email.trim())) {
      setStatus("error");
      return;
    }
    setStatus("success");
  }

  function handleRegion(e: ChangeEvent<HTMLSelectElement>): void {
    setRegion(e.target.value);
  }

  function toggle(id: string): void {
    setOpen((prev) => ({ ...prev, [id]: !prev[id] }));
  }

  function toTop(): void {
    window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
  }

  return (
    <section
      className="relative w-full overflow-hidden border-t border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300"
      aria-labelledby={`${uid}-footer-heading`}
    >
      <style>{`
        @keyframes footx-ping {
          75%, 100% { transform: scale(2.4); opacity: 0; }
        }
        @keyframes footx-sheen {
          0% { background-position: -140% 0; }
          100% { background-position: 240% 0; }
        }
        .footx-ping { animation: footx-ping 2s cubic-bezier(0,0,0.2,1) infinite; }
        .footx-mark {
          background-image: linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.55) 50%, transparent 70%);
          background-size: 220% 100%;
          animation: footx-sheen 5.5s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .footx-ping, .footx-mark { animation: none !important; }
        }
      `}</style>

      {/* Decorative atmosphere */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="absolute -left-32 -top-40 h-80 w-80 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15" />
        <div className="absolute -right-24 top-10 h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-700/15" />
        <div
          className="absolute inset-0 opacity-[0.5] dark:opacity-[0.35]"
          style={{
            backgroundImage:
              "linear-gradient(to right, rgba(100,116,139,0.09) 1px, transparent 1px), linear-gradient(to bottom, rgba(100,116,139,0.09) 1px, transparent 1px)",
            backgroundSize: "56px 56px",
            maskImage: "radial-gradient(ellipse 90% 60% at 50% 0%, #000 40%, transparent 100%)",
            WebkitMaskImage: "radial-gradient(ellipse 90% 60% at 50% 0%, #000 40%, transparent 100%)",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-7xl px-6 py-16 sm:px-8 sm:py-20">
        <h2 id={`${uid}-footer-heading`} className="sr-only">
          Pulsegrid site footer
        </h2>

        {/* Newsletter band */}
        <motion.div
          initial={reduce ? false : { opacity: 0, y: 18 }}
          whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.5, ease: "easeOut" }}
          className="flex flex-col gap-8 border-b border-slate-200 pb-12 dark:border-slate-800 lg:flex-row lg:items-center lg:justify-between"
        >
          <div className="max-w-md">
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
              The Incident Report
            </p>
            <h3 className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
              Learn from outages before they reach you
            </h3>
            <p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              One email a month: a real postmortem, a monitoring pattern worth stealing, and what
              changed in Pulsegrid. Read by 41,000 on-call engineers.
            </p>
          </div>

          <form onSubmit={handleSubmit} noValidate className="w-full lg:max-w-md">
            <label htmlFor={emailId} className="sr-only">
              Work email address
            </label>
            <div className="flex flex-col gap-3 sm:flex-row">
              <input
                id={emailId}
                name="email"
                type="email"
                autoComplete="email"
                inputMode="email"
                placeholder="you@company.com"
                value={email}
                aria-invalid={status === "error"}
                aria-describedby={msgId}
                onChange={(e) => {
                  setEmail(e.target.value);
                  if (status !== "idle") setStatus("idle");
                }}
                className={`w-full rounded-xl border bg-white px-4 py-3 text-sm text-slate-900 placeholder:text-slate-400 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 ${FOCUS} ${
                  status === "error"
                    ? "border-rose-400 dark:border-rose-500/70"
                    : "border-slate-300 dark:border-slate-700"
                }`}
              />
              <button
                type="submit"
                className={`inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-indigo-600 dark:bg-white dark:text-slate-900 dark:hover:bg-indigo-400 ${FOCUS} dark:focus-visible:ring-offset-slate-950`}
              >
                Subscribe
                <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                  <path
                    d="M4 10h11m0 0-4-4m4 4-4 4"
                    stroke="currentColor"
                    strokeWidth="1.75"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              </button>
            </div>
            <p
              id={msgId}
              role="status"
              aria-live="polite"
              className={`mt-2.5 flex items-center gap-1.5 text-xs ${
                status === "error"
                  ? "text-rose-600 dark:text-rose-400"
                  : status === "success"
                    ? "text-emerald-600 dark:text-emerald-400"
                    : "text-slate-500 dark:text-slate-500"
              }`}
            >
              {status === "success" ? (
                <>
                  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
                    <path
                      fillRule="evenodd"
                      d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0l-3.5-3.5a1 1 0 1 1 1.4-1.4l2.8 2.79 6.8-6.79a1 1 0 0 1 1.4 0Z"
                      clipRule="evenodd"
                    />
                  </svg>
                  You&apos;re in. Check your inbox to confirm.
                </>
              ) : status === "error" ? (
                "Please enter a valid email address."
              ) : (
                "Monthly. No spam. Unsubscribe in one click."
              )}
            </p>
          </form>
        </motion.div>

        {/* Columns */}
        <div className="grid grid-cols-2 gap-x-8 gap-y-10 pt-14 sm:grid-cols-2 lg:grid-cols-6">
          {/* Brand block */}
          <div className="col-span-2 lg:col-span-2">
            <a
              href="#"
              className={`inline-flex items-center gap-2.5 rounded-lg ${FOCUS}`}
              aria-label="Pulsegrid home"
            >
              <span className="footx-mark flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/25">
                <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
                  <path
                    d="M2 13h4l2.5-7 4 14 3-11 2 4h4.5"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              </span>
              <span className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
                Pulsegrid
              </span>
            </a>

            <p className="mt-4 max-w-xs text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Uptime monitoring, status pages, and on-call scheduling that engineering teams
              actually trust when the pager goes off.
            </p>

            <div className="mt-5 inline-flex items-center gap-2 rounded-full border border-emerald-300/60 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
              <span className="relative flex h-2.5 w-2.5" aria-hidden="true">
                <span className="footx-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
                <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
              </span>
              All systems operational
            </div>

            <ul className="mt-6 flex flex-wrap gap-2.5">
              {SOCIALS.map((s) => (
                <li key={s.label}>
                  <a
                    href={s.href}
                    target="_blank"
                    rel="noopener"
                    aria-label={s.label}
                    className={`inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition-colors hover:border-indigo-400 hover:text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400 ${FOCUS}`}
                  >
                    {s.icon}
                  </a>
                </li>
              ))}
            </ul>
          </div>

          {/* Link columns — accordion on mobile, static on sm+ */}
          {COLUMNS.map((col) => {
            const isOpen = !!open[col.id];
            const panelId = `${uid}-${col.id}-panel`;
            return (
              <div key={col.id} className="border-t border-slate-200 py-2 dark:border-slate-800 sm:border-t-0 sm:py-0">
                <h3>
                  <button
                    type="button"
                    onClick={() => toggle(col.id)}
                    aria-expanded={isOpen}
                    aria-controls={panelId}
                    className={`flex w-full items-center justify-between rounded-md py-2 text-left text-sm font-semibold text-slate-900 dark:text-white sm:pointer-events-none sm:cursor-default sm:py-0 ${FOCUS}`}
                  >
                    {col.title}
                    <svg
                      viewBox="0 0 20 20"
                      fill="none"
                      aria-hidden="true"
                      className={`h-4 w-4 text-slate-400 transition-transform duration-200 sm:hidden ${
                        isOpen ? "rotate-180" : ""
                      }`}
                    >
                      <path
                        d="m5 7.5 5 5 5-5"
                        stroke="currentColor"
                        strokeWidth="1.75"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </button>
                </h3>
                <ul
                  id={panelId}
                  className={`space-y-3 pb-2 pt-1 sm:mt-4 sm:block sm:pb-0 sm:pt-0 ${
                    isOpen ? "block" : "hidden"
                  }`}
                >
                  {col.links.map((link) => (
                    <li key={link.label}>
                      <a
                        href={link.href}
                        className={`group inline-flex items-center gap-2 rounded-md text-sm text-slate-600 transition-colors hover:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-400 ${FOCUS}`}
                      >
                        {link.label}
                        {link.badge ? (
                          <span className="rounded-full bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none tracking-wide text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
                            {link.badge}
                          </span>
                        ) : null}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            );
          })}
        </div>

        {/* Bottom bar */}
        <div className="mt-14 flex flex-col gap-6 border-t border-slate-200 pt-8 dark:border-slate-800 md:flex-row md:items-center md:justify-between">
          <div className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-500 sm:flex-row sm:items-center sm:gap-4">
            <span>© 2026 Pulsegrid, Inc. All rights reserved.</span>
            <span className="hidden sm:inline" aria-hidden="true">
              ·
            </span>
            <span>
              Prices shown in{" "}
              <span className="font-medium text-slate-700 dark:text-slate-300">
                {activeRegion.currency}
              </span>
            </span>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <label htmlFor={`${uid}-region`} className="text-xs text-slate-500 dark:text-slate-500">
              Region
            </label>
            <div className="relative">
              <select
                id={`${uid}-region`}
                value={region}
                onChange={handleRegion}
                className={`appearance-none rounded-lg border border-slate-300 bg-white py-2 pl-3 pr-9 text-xs font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 ${FOCUS}`}
              >
                {REGIONS.map((r) => (
                  <option key={r.value} value={r.value}>
                    {r.label}
                  </option>
                ))}
              </select>
              <svg
                viewBox="0 0 20 20"
                fill="none"
                aria-hidden="true"
                className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
              >
                <path
                  d="m5 7.5 5 5 5-5"
                  stroke="currentColor"
                  strokeWidth="1.75"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </div>

            <button
              type="button"
              onClick={toTop}
              className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition-colors hover:border-indigo-400 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-400 ${FOCUS}`}
            >
              Back to top
              <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                <path
                  d="m5 12.5 5-5 5 5"
                  stroke="currentColor"
                  strokeWidth="1.75"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →