Footer ASCII Rule

Footer

A sitemap footer whose back-to-top control is a real instrument: an aria-hidden vertical rail beside it continuously reads actual scroll position, and the button drives a real spring back to the top rather than a jump — grabbing the wheel mid-flight yields it immediately.

Install
npx shadcn add https://design.helpmarq.com/r/footer-ascii-rule.json
Source
registry/core/footer-ascii-rule/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// FooterAsciiRule — a footer whose "back to top" control is an honest
// instrument, not a decoration: an aria-hidden vertical rail beside the
// copyright line always reads real, continuous scroll position (a passive
// scroll listener, not a fixed-duration animation on its own clock), and its
// button drives a real semi-implicit-Euler spring toward scrollY=0 rather
// than a native instant jump or CSS smooth-scroll. Because the rail's
// position comes from window.scrollY every frame, a user who grabs the
// wheel or a key mid-flight sees the car respond to what they're actually
// doing — and the flight itself is cancelled the instant that happens,
// rather than fighting the user for control of the page.
// ---------------------------------------------------------------------------

const RAIL_ROWS = 6;
const SPRING_K = 120;
const SPRING_C = 22;
const SETTLE_POS_EPS = 0.6;
const SETTLE_VEL_EPS = 4;
const SETTLE_MS = 2500;

export interface FooterLinkItem {
  label: string;
  href: string;
}

export interface FooterColumn {
  heading: string;
  links: FooterLinkItem[];
}

export interface FooterAsciiRuleProps {
  brand?: string;
  columns: FooterColumn[];
  className?: string;
}

export function FooterAsciiRule({ brand = "ns-ui", columns, className = "" }: FooterAsciiRuleProps) {
  const railRef = useRef<HTMLPreElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const flightRef = useRef<{ raf: number; cancel: () => void } | null>(null);

  // -- honest readout: the rail always reflects real scroll, all the time --
  useEffect(() => {
    const rail = railRef.current;
    if (!rail) return;

    const paint = () => {
      const doc = document.documentElement;
      const max = Math.max(1, doc.scrollHeight - window.innerHeight);
      const progress = Math.min(1, Math.max(0, window.scrollY / max));
      // progress 0 (at top) -> car at row 0; progress 1 (scrolled away) -> car at last row
      const carRow = Math.round(progress * (RAIL_ROWS - 1));
      const lines: string[] = [];
      for (let i = 0; i < RAIL_ROWS; i++) lines.push(i === carRow ? "●" : "│");
      rail.textContent = lines.join("\n");
    };

    let ticking = false;
    const onScroll = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        ticking = false;
        paint();
      });
    };
    paint();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);

  useEffect(() => {
    return () => flightRef.current?.cancel();
  }, []);

  const backToTop = () => {
    flightRef.current?.cancel();

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) {
      window.scrollTo(0, 0);
      return;
    }

    let y = window.scrollY;
    let v = 0;
    const deadline = performance.now() + SETTLE_MS;
    let raf = 0;
    let cancelled = false;

    const stopInterrupt = () => {
      window.removeEventListener("wheel", onInterrupt);
      window.removeEventListener("touchstart", onInterrupt);
      window.removeEventListener("keydown", onKeyInterrupt);
    };
    const onInterrupt = () => cancel();
    const onKeyInterrupt = (e: KeyboardEvent) => {
      if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key)) cancel();
    };

    function cancel() {
      if (cancelled) return;
      cancelled = true;
      cancelAnimationFrame(raf);
      stopInterrupt();
      flightRef.current = null;
    }

    window.addEventListener("wheel", onInterrupt, { passive: true });
    window.addEventListener("touchstart", onInterrupt, { passive: true });
    window.addEventListener("keydown", onKeyInterrupt);

    let last = performance.now();
    const tick = (now: number) => {
      if (cancelled) return;
      const dt = Math.min(0.032, (now - last) / 1000);
      last = now;
      v += (-SPRING_K * y - SPRING_C * v) * dt;
      y += v * dt;
      const settled = Math.abs(y) < SETTLE_POS_EPS && Math.abs(v) < SETTLE_VEL_EPS;
      if (settled || now >= deadline) {
        window.scrollTo(0, 0);
        cancel();
        return;
      }
      window.scrollTo(0, Math.max(0, y));
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    flightRef.current = { raf, cancel };
  };

  return (
    <footer data-footer-ascii-rule className={`border-t border-border bg-surface ${className}`}>
      <div className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
        <div aria-hidden className="select-none font-mono text-[11px] text-border">
          {"─".repeat(64)}
        </div>

        <div className="mt-8 grid grid-cols-2 gap-8 sm:grid-cols-4">
          {columns.map((col) => (
            <div key={col.heading}>
              <h2 className="font-mono text-xs font-semibold uppercase tracking-[0.15em] text-foreground">
                {col.heading}
              </h2>
              <ul className="mt-3 flex flex-col gap-2">
                {col.links.map((link) => (
                  <li key={link.href}>
                    <a
                      href={link.href}
                      className="rounded-sm text-sm text-muted transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
                    >
                      {link.label}
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>

        <div className="mt-10 flex items-center justify-between gap-4 border-t border-border pt-6">
          <p className="font-mono text-xs text-muted">
            © {new Date().getFullYear()} {brand}
          </p>

          <button
            ref={buttonRef}
            type="button"
            onClick={backToTop}
            aria-label="Back to top"
            className="group inline-flex items-center gap-2 rounded-sm border border-border px-2.5 py-1.5 font-mono text-xs text-foreground transition-colors hover:border-foreground/25 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
          >
            <pre
              ref={railRef}
              aria-hidden
              className="pointer-events-none whitespace-pre text-center leading-[1.1] text-muted transition-colors group-hover:text-accent"
            />
            <span>back to top</span>
          </button>
        </div>
      </div>
    </footer>
  );
}
Build spec

Build <FooterAsciiRule brand? columns className?> where columns is FooterColumn[] ({heading, links: {label, href}[]}). STRUCTURE: a <footer data-footer-ascii-rule> with a static, decorative top rule (a repeated ─ character, aria-hidden — this is house-style dressing, not the mechanic), a responsive grid of sitemap columns (real <a> links, hover text-muted to text-foreground, focus-visible:outline-2 outline-offset-2 outline-accent), and a bottom row holding a copyright line (auto-computed year) and a 'back to top' button. THE MECHANIC — the one only a footer's job (closing the page and offering a way back) makes sense of: a 6-row aria-hidden <pre> rail sits beside the button and renders │ characters with one ● marking the current scroll position, mapped from window.scrollY / (scrollHeight − innerHeight) to a row index. This rail is driven by a passive, rAF-throttled scroll listener that runs continuously and unconditionally — not only while the button is mid-flight — so it is an honest, always-live readout of real scroll position, never a decoration animating on its own clock. Clicking the button does not call scrollIntoView or a native smooth scroll: it starts a semi-implicit-Euler spring (stiffness 120, damping 22, mass 1) seeded at the current scrollY with zero velocity (shortcut taken: release velocity isn't measured here, unlike drawer-counterweight's drag, since there is no drag gesture to sample it from) and integrates it in a requestAnimationFrame loop that calls window.scrollTo(0, y) every frame — which is itself what feeds the rail's existing scroll listener, so the car's motion during the flight is the same code path as at rest, not a duplicate animation. If the user grabs the wheel, touches the screen, or presses an arrow/Home/End/PageUp/PageDown/Space key while the spring is in flight, temporary listeners cancel the animation immediately and hand control back — the page never fights the user for who is scrolling it. The flight force-settles at 2.5s if the spring hasn't converged (a forced deadline, matching the pattern used elsewhere in this registry for spring-driven UI) and snaps to exactly 0 either way. prefers-reduced-motion skips the spring entirely and jumps straight to window.scrollTo(0, 0). Colors are token-only (--border, --foreground, --muted, --accent, --surface) with no hex, including the rail's plain-text glyphs which inherit currentColor from Tailwind text-* classes. Demo page is a real, moderately tall scroll ahead of the footer (not a scripted auto-scroll) so /preview stays the honest interactive reference.

Tags
footersitemapback-to-topscrollspringasciimono