# Echo Reel Footer

> A night footer that ends on a giant wordmark. Point at a link column, or Tab into it, and the Echo Reel at the foot turns to that column's name and inks it in, then turns back to the brand a beat after you leave. With a newsletter field, three link columns, the legal line and a status light.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/echo-reel-footer).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/echo-reel-footer
- Screenshot: https://shadcn.exchange/shot/keyframeui/echo-reel-footer/light.png
- Registry item: https://keyframeui.com/r/echo-reel-footer.json
- Categories: blocks, footers
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/echo-reel-footer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/echo-reel.json`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/footers/echo-reel-footer

## Files

### `registry/blocks/echo-reel-footer.tsx`

```tsx
"use client";

import * as React from "react";

import { EchoReel } from "@/components/ui/echo-reel";

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the footer yours: the
 * palette, the columns, the newsletter and the legal line.
 * The giant wordmark at the foot is an Echo Reel: point at or
 * Tab into a column and it turns to that column's name, then
 * back to yours a beat after you leave.
 * ─────────────────────────────────────────────────────────── */

/** A night band in both schemes, with the violet as the ink. */
const PALETTE = { paper: "#07070c", ink: "#f5f5f7", accent: "#9b83ff", lime: "#c6ff3d" };

const BRAND = { name: "Loupe", line: "Studio product photos from one phone shot.", href: "#" };
const COLUMNS = [
  {
    title: "Product",
    links: [
      { label: "Cutouts", href: "#cutouts" },
      { label: "Scenes", href: "#scenes" },
      { label: "Colorways", href: "#colorways" },
      { label: "Pricing", href: "#pricing" },
    ],
  },
  {
    title: "Company",
    links: [
      { label: "About", href: "#about" },
      { label: "Careers", href: "#careers" },
      { label: "Press kit", href: "#press" },
    ],
  },
  {
    title: "Help",
    links: [
      { label: "Guides", href: "#guides" },
      { label: "Changelog", href: "#changelog" },
      { label: "Status", href: "#status" },
      { label: "Contact", href: "#contact" },
    ],
  },
];
const NEWSLETTER = { label: "One email a month with what we shipped.", placeholder: "you@shop.com", button: "Subscribe", done: "Subscribed" };
const LEGAL = { owner: "© 2026 Loupe Labs, Inc.", links: [{ label: "Privacy", href: "#privacy" }, { label: "Terms", href: "#terms" }] };
const STATUS = "All systems normal";

/** How long the wordmark waits before turning back to the brand. */
const RETURN_MS = 250;

/* ── Section ─────────────────────────────────────────────── */

const WORDS = [BRAND.name, ...COLUMNS.map((column) => column.title)];

export function EchoReelFooter() {
  const [word, setWord] = React.useState(0);
  const [subscribed, setSubscribed] = React.useState(false);
  const back = React.useRef<number | undefined>(undefined);

  const show = (index: number) => {
    window.clearTimeout(back.current);
    setWord(index);
  };
  // A beat before turning back, so moving from one column to the next turns the reel once.
  const release = () => {
    window.clearTimeout(back.current);
    back.current = window.setTimeout(() => setWord(0), RETURN_MS);
  };
  React.useEffect(() => () => window.clearTimeout(back.current), []);

  return (
    <footer
      style={{ "--paper": PALETTE.paper, "--ink": PALETTE.ink, "--accent": PALETTE.accent, "--lime": PALETTE.lime } as React.CSSProperties}
      className="relative overflow-hidden bg-(--paper) text-(--ink)"
    >
      <div className="mx-auto max-w-6xl px-6 pt-16 sm:pt-20">
        <div className="grid gap-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-20">
          <div className="max-w-sm">
            <a
              href={BRAND.href}
              className="inline-flex items-center gap-2 rounded-md text-[17px] font-semibold tracking-[-0.03em] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
            >
              <LoupeMark className="size-6" />
              {BRAND.name}
            </a>
            <p className="mt-3 text-[15px] leading-relaxed opacity-60">{BRAND.line}</p>
            <form
              className="mt-7"
              onSubmit={(e) => {
                e.preventDefault();
                setSubscribed(true);
              }}
            >
              <label htmlFor="loupe-newsletter" className="text-[13px] font-medium opacity-80">
                {NEWSLETTER.label}
              </label>
              <div className="mt-2.5 flex items-center gap-1.5 rounded-full bg-white/[0.06] p-1.5 ring-1 ring-white/10 focus-within:ring-(--accent)">
                <input
                  id="loupe-newsletter"
                  type="email"
                  required
                  placeholder={NEWSLETTER.placeholder}
                  className="h-9 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-white/35"
                />
                <button
                  type="submit"
                  className="h-9 shrink-0 rounded-full bg-(--accent) px-4 text-sm font-semibold text-(--paper) transition-[opacity,scale] duration-150 hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) active:scale-[0.97]"
                >
                  {subscribed ? NEWSLETTER.done : NEWSLETTER.button}
                </button>
              </div>
            </form>
          </div>

          <nav aria-label="Footer" className="grid grid-cols-2 gap-x-12 gap-y-10 sm:grid-cols-3">
            {COLUMNS.map((column, i) => (
              <div
                key={column.title}
                onPointerEnter={() => show(i + 1)}
                onPointerLeave={release}
                onFocus={() => show(i + 1)}
                onBlur={release}
              >
                <p className="text-[13px] font-semibold text-(--accent)">{column.title}</p>
                <ul className="mt-4 space-y-2.5">
                  {column.links.map((link) => (
                    <li key={link.label}>
                      <a
                        href={link.href}
                        className="-mx-1 rounded px-1 py-0.5 text-[14px] opacity-65 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-(--accent)"
                      >
                        {link.label}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </nav>
        </div>

        <div className="mt-14 flex flex-col gap-4 border-t border-white/10 pt-6 text-[13px] sm:flex-row sm:items-center sm:justify-between">
          <div className="flex flex-wrap items-center gap-x-5 gap-y-2 opacity-55">
            <span>{LEGAL.owner}</span>
            {LEGAL.links.map((link) => (
              <a key={link.label} href={link.href} className="rounded hover:opacity-100 focus-visible:outline-2 focus-visible:outline-(--accent)">
                {link.label}
              </a>
            ))}
          </div>
          <p className="flex items-center gap-2 opacity-70">
            <span aria-hidden className="size-2 rounded-full bg-(--lime) shadow-[0_0_0_3px_rgb(198_255_61/0.15)]" />
            {STATUS}
          </p>
        </div>
      </div>

      {/* The wordmark sits on the foot of the page; its lower proof runs off the edge. */}
      <div className="mx-auto mt-6 -mb-[13%] max-w-5xl px-2 sm:px-6 lg:-mb-[12%]">
        <EchoReel words={WORDS} value={word} onValueChange={(index) => setWord(index)} interval={false} className="text-(--accent)" />
      </div>
    </footer>
  );
}

/* ── Mark ────────────────────────────────────────────────── */

function LoupeMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden className={className}>
      <circle cx="10.5" cy="10.5" r="6.5" stroke="currentColor" strokeWidth="2.6" />
      <path d="M15.5 15.5 20 20" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" />
      <circle cx="10.5" cy="10.5" r="2.6" fill="var(--accent)" />
    </svg>
  );
}
```
