Skip to main content

ns-ui

Footer ASCII Rule

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.

Usage

registry/core/footer-ascii-rule/demo.tsx
"use client";

import { FooterAsciiRule } from "./component";

const COLUMNS = [
  {
    heading: "Product",
    links: [
      { label: "Overview", href: "#overview" },
      { label: "Pricing", href: "#pricing" },
      { label: "Changelog", href: "#changelog" },
    ],
  },
  {
    heading: "Resources",
    links: [
      { label: "Docs", href: "#docs" },
      { label: "Guides", href: "#guides" },
      { label: "API", href: "#api" },
    ],
  },
  {
    heading: "Company",
    links: [
      { label: "About", href: "#about" },
      { label: "Blog", href: "#blog" },
      { label: "Careers", href: "#careers" },
    ],
  },
  {
    heading: "Legal",
    links: [
      { label: "Privacy", href: "#privacy" },
      { label: "Terms", href: "#terms" },
    ],
  },
];

export default function FooterAsciiRuleDemo() {
  return (
    <div className="flex min-h-[130vh] flex-col">
      <main className="mx-auto max-w-5xl flex-1 px-4 py-16 sm:px-6">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / footer-ascii-rule
        </p>
        <h1 className="mt-4 max-w-lg text-2xl font-semibold text-foreground sm:text-3xl">
          Scroll down, then use "back to top" — the rail beside it is a real
          scroll readout the whole time, and the button springs the page
          back rather than jumping.
        </h1>
        <p className="mt-4 max-w-lg text-sm leading-relaxed text-ns-muted">
          Grab the wheel mid-flight and the spring yields immediately.
        </p>
      </main>
      <FooterAsciiRule brand="ns-ui" columns={COLUMNS} />
    </div>
  );
}

Component

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 text on the left */
  brand?: string;
  /** the footer's link columns */
  columns: FooterColumn[];
  /** extra classes merged onto the rendered root element */
  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-ns-muted transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-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-ns-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-ns-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            <pre
              ref={railRef}
              aria-hidden
              className="pointer-events-none whitespace-pre text-center leading-[1.1] text-ns-muted transition-colors group-hover:text-ns-accent"
            />
            <span>back to top</span>
          </button>
        </div>
      </div>
    </footer>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/footer-ascii-rule.tsx, so point the import there.

Install

npx shadcn add https://design.helpmarq.com/r/footer-ascii-rule.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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-ns-muted to text-foreground, focus-visible:outline-2 outline-offset-2 outline-ns-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, --ns-muted, --ns-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.

Props

PropTypeDefaultDescription
brand?string"ns-ui"brand text on the left
columnsFooterColumn[]—the footer's link columns
className?string—extra classes merged onto the rendered root element