# Magnetic ASCII Footer

> A site footer with scramble headlines, flicker CTAs, and a magnetic ASCII wordmark that shatters and reforms on click.

**Definition:** Magnetic ASCII Footer is a components UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Components
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-09-04
- Canonical page: https://html.bosconvertpro.com/collection/magnetic-ascii-footer
- Full-page preview: https://html.bosconvertpro.com/collection/magnetic-ascii-footer/full
- Live demo: https://html.bosconvertpro.com/demos/magnetic-ascii-footer/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/magnetic-ascii-footer/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/magnetic-ascii-footer/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/magnetic-ascii-footer.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Magnetic ASCII Footer?

Magnetic ASCII Footer is a components UI animation from collections.html. A site footer with scramble headlines, flicker CTAs, and a magnetic ASCII wordmark that shatters and reforms on click.

### What is Magnetic ASCII Footer built with?

Magnetic ASCII Footer is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Magnetic ASCII Footer in my project?

Open the animation page (https://html.bosconvertpro.com/collection/magnetic-ascii-footer), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/magnetic-ascii-footer/index.html.

### Is Magnetic ASCII Footer free to use?

Yes. The Magnetic ASCII Footer demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Magnetic ASCII Footer</title>
    <meta
      name="description"
      content="Footer with scramble headlines, flicker CTAs, and a magnetic ASCII wordmark."
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <!-- Top CTA: live clocks + headline + big action links -->
    <div class="section footer-cta">
      <div class="page-padding">
        <div class="page-container section-spacing">
          <div class="cta-block">
            <div class="cta-row timezone-row">
              <div class="opacity-60">
                <div class="timezone-user" id="userTime">Your Timezone (US) 00:00:00</div>
              </div>
              <div class="opacity-40">
                <div class="timezone-studio" id="brTime">Abbos Timezone (UZ) 00:00:00</div>
              </div>
            </div>

            <div class="cta-row">
              <div class="cta-heading">
                <h2
                  class="display-title text-light"
                  data-scramble-scroll
                >
                  Let&rsquo;s build<br />something together
                </h2>
              </div>

              <div class="cta-links">
                <div class="action-list">
                  <div class="action-item">
                    <a
                      href="/contact"
                      data-scramble-hover="link"
                      data-hover=""
                      class="action-link"
                    >
                      <h3 class="action-label" data-scramble-hover="target">Get a quote</h3>
                      <span class="action-icon" aria-hidden="true">
                        <span class="action-icon-svg">
                          <svg viewBox="0 0 11 11" width="100%" height="100%" fill="none">
                            <path
                              d="M1.28 10.68 0 9.42l7.7-7.7H.26L1.98 0h8.7v8.72l-1.7 1.72V2.98L1.28 10.68Z"
                              fill="currentColor"
                            />
                          </svg>
                        </span>
                      </span>
                      <span class="action-divider" aria-hidden="true"></span>
                    </a>
                  </div>

                  <div class="action-item">
                    <a
                      href="/careers"
                      data-scramble-hover="link"
                      data-hover=""
                      class="action-link"
                    >
                      <h3 class="action-label" data-scramble-hover="target">Join our team</h3>
                      <span class="action-icon" aria-hidden="true">
                        <span class="action-icon-svg">
                          <svg viewBox="0 0 11 11" width="100%" height="100%" fill="none">
                            <path
                              d="M1.28 10.68 0 9.42l7.7-7.7H.26L1.98 0h8.7v8.72l-1.7 1.72V2.98L1.28 10.68Z"
                              fill="currentColor"
                            />
                          </svg>
                        </span>
                      </span>
                      <span class="action-divider" aria-hidden="true"></span>
                    </a>
                  </div>

                  <div class="action-item">
                    <a
                      href="#"
                      data-scramble-hover="link"
                      data-hover=""
                      class="action-link"
                    >
                      <h3 class="action-label" data-scramble-hover="target">Just say hello</h3>
                      <span class="action-icon" aria-hidden="true">
                        <span class="action-icon-svg">
                          <svg viewBox="0 0 11 11" width="100%" height="100%" fill="none">
                            <path
                              d="M1.28 10.68 0 9.42l7.7-7.7H.26L1.98 0h8.7v8.72l-1.7 1.72V2.98L1.28 10.68Z"
                              fill="currentColor"
                            />
                          </svg>
                        </span>
                      </span>
                      <span class="action-divider" aria-hidden="true"></span>
                    </a>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <footer class="site-footer">
      <div class="page-padding">
        <div class="page-container">
          <div class="footer-inner">
            <div class="brand-canvas" id="asciiWrap">
              <canvas id="asciiCanvas"></canvas>
            </div>

            <div class="footer-body">
              <div class="footer-grid">
                <div class="footer-col footer-col--location">
                  <div class="body-text text-light">Location</div>
                  <div class="body-text text-muted">
                    Tashkent<br />Uzbekistan
                  </div>
                </div>

                <div class="footer-link-group">
                  <div class="footer-col">
                    <a href="#" class="footer-link footer-link--first">Home</a>
                    <a href="#" class="footer-link">Work</a>
                    <a href="#" class="footer-link">About</a>
                    <a href="#" class="footer-link footer-link--last">Contact</a>
                  </div>
                  <div class="footer-col">
                    <a href="#" class="footer-link footer-link--first">Instagram</a>
                    <a href="#" class="footer-link">Behance</a>
                    <a href="#" class="footer-link footer-link--last">Linkedin</a>
                  </div>
                </div>
              </div>

              <div class="footer-grid footer-bottom">
                <div class="footer-col footer-col--location">
                  <div class="body-text text-muted">
                    © Abbos 2026
                  </div>
                </div>
                <div class="footer-link-group legal">
                  <a href="#" class="body-text text-muted">Privacy Notice</a>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </footer>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ============================================
   Magnetic ASCII Footer — clean, readable classes
   Same look: Neue Haas Grotesk, fluid em type
   Base: body .833vw / 1.615vw / 2.086vw / 3.738vw
   ============================================ */

@font-face {
  font-family: "Neue Haas Grotesk Text Pro 55 Roman";
  src: url("https://cdn.prod.website-files.com/6a1dba8694fc8f455cf5585d/6a1dba8694fc8f455cf5586b_neue-haas-grotesk-text-pro-55-roman.ttf")
    format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  --white: #fff;
  --black: #000;
  --hairline: #ffffff29; /* thin borders + dividers */
  --muted: #ffffffa3; /* secondary text */
  --ease-out: cubic-bezier(0.625, 0.05, 0, 1);
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  height: 100%;
}

body {
  background-color: var(--black);
  color: var(--white);
  font-family: "Neue Haas Grotesk Text Pro 55 Roman", Arial, sans-serif;
  font-size: 0.833vw;
  line-height: 1.5;
  margin: 0;
  min-height: 100%;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: clip;
}

h1,
h2,
h3,
h4,
h5,
h6,
p {
  margin-top: 0;
  margin-bottom: 0;
}

a {
  color: inherit;
  text-decoration: none;
}

::selection {
  background: var(--white);
  color: var(--black);
}

/* ---------- Page frame ---------- */

.page-padding {
  padding-left: 2.5em;
  padding-right: 2.5em;
}

.page-container {
  width: 100%;
  max-width: 114.979em;
  margin-left: auto;
  margin-right: auto;
}

.section-spacing {
  padding-top: 8em;
  padding-bottom: 8em;
}

.section {
  position: relative;
}

.footer-cta {
  background-color: var(--black);
}

/* ---------- Type ---------- */

.display-title {
  letter-spacing: -0.03em;
  font-size: 3em;
  font-weight: 400;
  line-height: 1.1;
}

.body-text {
  letter-spacing: -0.019em;
  font-size: 1em;
}

.text-light {
  color: var(--white);
}

.text-muted {
  color: var(--muted);
}

.opacity-60 {
  opacity: 0.6;
}

.opacity-40 {
  opacity: 0.4;
}

.timezone-user,
.timezone-studio {
  color: var(--white);
  letter-spacing: -0.04em;
  font-size: 1em;
}

/* Split helpers for the scramble animations (sizes unchanged) */
.word {
  display: inline-block;
  white-space: nowrap;
}

.char {
  display: inline-block;
  will-change: transform, opacity;
}

/* ---------- Top CTA: clocks + headline + action links ---------- */

.cta-block {
  grid-column-gap: 5em;
  grid-row-gap: 5em;
  flex-flow: column;
  justify-content: space-between;
  align-items: flex-start;
  display: flex;
}

.cta-row {
  grid-column-gap: 1em;
  grid-row-gap: 1em;
  flex-flow: wrap;
  justify-content: space-between;
  align-items: flex-start;
  width: 100%;
  display: flex;
}

.cta-heading {
  grid-column-gap: 5em;
  grid-row-gap: 5em;
  flex-flow: column;
  justify-content: flex-start;
  align-items: flex-start;
  max-width: 36.125em;
  display: flex;
}

.cta-links {
  grid-column-gap: 5em;
  grid-row-gap: 5em;
  flex-flow: column;
  justify-content: flex-start;
  align-items: flex-end;
  width: 56.188em;
  display: flex;
}

.action-list {
  flex-flow: column;
  width: 100%;
  display: flex;
}

.action-item {
  list-style: none;
}

.action-link {
  grid-column-gap: 1em;
  grid-row-gap: 1em;
  cursor: pointer;
  justify-content: space-between;
  align-items: center;
  padding-top: 2em;
  padding-bottom: 2em;
  padding-right: 1em;
  display: flex;
  position: relative;
  will-change: transform;
}

.action-label {
  color: var(--white);
  margin-top: 0;
  margin-bottom: 0;
  font-size: 1.25em;
  font-weight: 500;
  line-height: 1.3;
}

.action-icon {
  flex-shrink: 0;
  justify-content: center;
  align-items: center;
  width: 1em;
  height: 1em;
  display: flex;
}

.action-icon-svg {
  width: 1em;
  color: var(--white);
  justify-content: center;
  align-items: center;
  display: flex;
  transform: rotate(0deg);
  transform-origin: center;
  transition: transform 0.45s var(--ease-out);
  will-change: transform;
}

.action-link:hover .action-icon-svg {
  transform: rotate(45deg);
}

.action-divider {
  width: 100%;
  height: 1px;
  background-color: var(--white);
  opacity: 0.2;
  position: absolute;
  inset: auto 0% 0%;
}

/* ---------- Footer: logo + link grid ---------- */

.site-footer {
  background-color: var(--black);
}

.footer-inner {
  grid-column-gap: 2em;
  grid-row-gap: 2em;
  border-top: 1px solid var(--hairline);
  flex-flow: column;
  display: flex;
}

.brand-canvas {
  position: relative;
  overflow: hidden;
  cursor: pointer;
  width: 100%;
  height: 30em;
  margin: 0;
}

.brand-canvas canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.footer-body {
  grid-column-gap: 4em;
  grid-row-gap: 4em;
  flex-flow: column;
  display: flex;
}

.footer-grid {
  grid-column-gap: 36.063em;
  grid-row-gap: 36.063em;
  justify-content: flex-start;
  align-items: flex-start;
  padding-top: 1em;
  padding-bottom: 1em;
  display: flex;
}

.footer-bottom {
  justify-content: space-between;
}

.footer-col {
  flex-flow: column;
  justify-content: flex-start;
  align-items: flex-start;
  width: 22.417em;
  display: flex;
}

.footer-col--location {
  grid-column-gap: 0.688em;
  grid-row-gap: 0.688em;
}

.footer-link-group {
  grid-column-gap: 6.875em;
  grid-row-gap: 6.875em;
  flex-flow: row;
  justify-content: flex-start;
  align-items: flex-start;
  display: flex;
}

.footer-link {
  position: relative;
  opacity: 0.6;
  color: var(--white);
  letter-spacing: -0.04em;
  padding-top: 0.6565em;
  padding-bottom: 0.6565em;
  font-size: 1em;
  line-height: 1.4;
  text-decoration: none;
  transition: opacity 0.3s ease;
}

.footer-link:hover {
  opacity: 1;
}

/* Underline: draws in from the left, wipes out to the right */
.footer-link::after,
.footer-link-group.legal a::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0.3em;
  width: 100%;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.45s var(--ease-out);
}

.footer-link:hover::after,
.footer-link-group.legal a:hover::after {
  transform: scaleX(1);
  transform-origin: left;
}

/* Last links have no bottom padding — drop the line below the
   text so the text-to-line gap matches the other links */
.footer-link--last::after,
.footer-link-group.legal a::after {
  bottom: -0.3em;
}

.footer-link--first {
  padding-top: 0;
}

.footer-link--last {
  padding-bottom: 0;
}

.footer-link-group.legal a {
  position: relative;
}

/* ---------- Responsive ---------- */

@media screen and (max-width: 991px) {
  body {
    font-size: 1.615vw;
  }

  .cta-block,
  .cta-heading {
    grid-column-gap: 2.5em;
    grid-row-gap: 2.5em;
  }

  .cta-links {
    grid-column-gap: 2em;
    grid-row-gap: 2em;
    justify-content: flex-start;
    align-items: flex-start;
    width: 100%;
  }

  .footer-inner {
    padding-top: 2em;
  }

  .footer-grid {
    grid-column-gap: 2em;
    grid-row-gap: 2em;
    flex-flow: column;
  }

  .footer-bottom {
    flex-flow: row;
    justify-content: space-between;
    align-items: flex-start;
  }

  .footer-link-group {
    justify-content: space-between;
    align-items: flex-start;
    width: 100%;
  }

  .footer-link-group.legal {
    width: 22.417em;
  }
}

@media screen and (max-width: 767px) {
  body {
    font-size: 2.086vw;
  }

  .page-padding {
    padding-left: 1em;
    padding-right: 1em;
  }

  .section-spacing {
    padding-top: 5em;
    padding-bottom: 5em;
  }

  .display-title {
    font-size: 2.25em;
  }

  .cta-block,
  .cta-heading {
    grid-column-gap: 2.5em;
    grid-row-gap: 2.5em;
  }

  .cta-links {
    width: 100%;
  }

  .brand-canvas {
    height: 22.75em;
  }

  .footer-grid {
    grid-column-gap: 5em;
    grid-row-gap: 5em;
  }

  .footer-bottom {
    grid-column-gap: 2.5em;
    grid-row-gap: 2.5em;
    flex-flow: row;
  }

  .footer-link-group.legal {
    justify-content: flex-end;
    align-items: center;
  }
}

@media screen and (max-width: 479px) {
  body {
    font-size: 3.738vw;
  }
}
````

## Source — script.js
````javascript
/**
 * Magnetic ASCII Footer
 * - Live dual timezones (user + Asia/Tashkent)
 * - Scramble headline on scroll
 * - Flicker hover on big action links (scale + char blink)
 * - Magnetic ASCII logo (fall / form / spread on click)
 * - Scroll reveals
 */

(() => {
  "use strict";

  if (typeof SplitText !== "undefined") gsap.registerPlugin(ScrollTrigger, SplitText);
  else gsap.registerPlugin(ScrollTrigger);

  const SCRAMBLE_SET = "^$%#@!&*?+=";
  const ASCII_SET = "01●○•#@&ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  // Same splitter the site uses when available.
  function splitChars(el) {
    if (typeof SplitText !== "undefined") {
      const split = new SplitText(el, { type: "chars", charsClass: "char" });
      return { chars: split.chars, revert: () => split.revert() };
    }
    return { chars: splitToChars(el), revert: () => {} };
  }

  /* ---------------- Live timezones ---------------- */

  function countryFromZone(zone) {
    const map = {
      UTC: "UTC",
      "Asia/Tashkent": "UZ",
      "America/Sao_Paulo": "BR",
      "America/New_York": "US",
      "America/Chicago": "US",
      "America/Denver": "US",
      "America/Los_Angeles": "US",
    };
    if (map[zone]) return map[zone];
    const [region, city] = zone.split("/");
    if (region === "America") {
      const us = ["New_York", "Chicago", "Denver", "Los_Angeles", "Phoenix", "Anchorage"];
      if (us.includes(city)) return "US";
      if (["Toronto", "Vancouver", "Montreal"].includes(city)) return "CA";
      if (city === "Mexico_City") return "MX";
      return "AM";
    }
    if (region === "Europe") return "EU";
    if (region === "Asia") return "AS";
    if (region === "Africa") return "AF";
    if (region === "Australia") return "AU";
    return "LOCAL";
  }

  function initClocks() {
    const userEl = document.getElementById("userTime");
    const brEl = document.getElementById("brTime");
    if (!userEl && !brEl) return;

    const userZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
    const userCode = countryFromZone(userZone);

    const userFmt = new Intl.DateTimeFormat("en-GB", {
      timeZone: userZone,
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
      hour12: false,
    });
    const brFmt = new Intl.DateTimeFormat("en-GB", {
      timeZone: "Asia/Tashkent",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
      hour12: false,
    });

    const tick = () => {
      const now = new Date();
      if (userEl) userEl.textContent = `Your Timezone (${userCode}) ${userFmt.format(now)}`;
      if (brEl) brEl.textContent = `Abbos Timezone (UZ) ${brFmt.format(now)}`;
    };
    tick();
    setInterval(tick, 1000);
  }

  /* ---------------- Text helpers ---------------- */

  function splitToChars(el) {
    // Split keeping <br> intact: walk child nodes.
    const frag = document.createDocumentFragment();
    const chars = [];

    const pushText = (text) => {
      // Preserve spaces, split words so lines can wrap.
      text.split(/(\s+)/).forEach((part) => {
        if (!part) return;
        if (/^\s+$/.test(part)) {
          frag.appendChild(document.createTextNode(" "));
          return;
        }
        const word = document.createElement("span");
        word.className = "word";
        [...part].forEach((c) => {
          const s = document.createElement("span");
          s.className = "char";
          s.textContent = c;
          s.dataset.orig = c;
          word.appendChild(s);
          chars.push(s);
        });
        frag.appendChild(word);
      });
    };

    [...el.childNodes].forEach((node) => {
      if (node.nodeName === "BR") frag.appendChild(document.createElement("br"));
      else if (node.nodeType === 3) pushText(node.textContent);
      else pushText(node.textContent);
    });

    el.innerHTML = "";
    el.appendChild(frag);
    return chars;
  }

  function scrambleReveal(chars, { duration = 1.2, stagger = 0.018 } = {}) {
    const state = { p: 0 };
    const total = chars.length;
    const originals = chars.map((c) => c.dataset.orig);

    // Start fully scrambled.
    chars.forEach((c) => {
      c.textContent = SCRAMBLE_SET[(Math.random() * SCRAMBLE_SET.length) | 0];
    });

    gsap.to(state, {
      p: 1,
      duration,
      ease: "power2.out",
      onUpdate: () => {
        const done = Math.floor(state.p * total);
        chars.forEach((c, i) => {
          if (i < done) c.textContent = originals[i];
          else if (Math.random() < 0.28) {
            c.textContent = SCRAMBLE_SET[(Math.random() * SCRAMBLE_SET.length) | 0];
          }
        });
      },
      onComplete: () => chars.forEach((c, i) => (c.textContent = originals[i])),
    });
  }

  function initScrambleHeadline() {
    document.querySelectorAll("[data-scramble-scroll]").forEach((el) => {
      const chars = splitToChars(el);
      gsap.set(chars, { opacity: 0, y: "0.4em" });
      ScrollTrigger.create({
        trigger: el,
        start: "top 85%",
        once: true,
        onEnter: () => {
          gsap.to(chars, {
            opacity: 1,
            y: "0em",
            duration: 0.7,
            stagger: 0.018,
            ease: "power3.out",
            overwrite: true,
          });
          scrambleReveal(chars, { duration: 1.4, stagger: 0.018 });
        },
      });
    });
  }

  /* Link type 1 — big CTA only (Get a quote / Join / Hello).
     Exact copy of the site's hover: scale 0.985 + char opacity flicker.
     Small footer links have NO js hover. */
  function initFlickerHover() {
    document.querySelectorAll('[data-scramble-hover="link"]').forEach((target) => {
      const label = target.querySelector('[data-scramble-hover="target"]');
      if (!label) return;

      let chars;
      if (typeof SplitText !== "undefined") {
        const split = new SplitText(label, { type: "chars", charsClass: "char" });
        chars = split.chars;
      } else {
        if (label.dataset.split === "1") return;
        label.dataset.split = "1";
        chars = splitToChars(label);
      }

      const flicker = () => {
        gsap.to(chars, {
          keyframes: [
            { opacity: 0, duration: 0.06, ease: "none" },
            { opacity: 1, duration: 0.04, ease: "none" },
            { opacity: 0, duration: 0.05, ease: "none" },
            { opacity: 1, duration: 0.08, ease: "none" },
          ],
          stagger: { each: 0.03, from: "random" },
          overwrite: true,
        });
      };

      target.addEventListener("mouseenter", () => {
        if (!prefersReduced) gsap.to(target, { scale: 0.985, duration: 0.35, ease: "power2.out", overwrite: true });
        flicker();
      });
      target.addEventListener("mouseleave", () => {
        gsap.to(target, { scale: 1, duration: 0.35, ease: "power2.out", overwrite: true });
        flicker();
      });
    });
  }

  /* ---------------- Scroll reveals ---------------- */

  function initReveals() {
    gsap.utils.toArray(".action-link").forEach((item, i) => {
      gsap.from(item, {
        y: 44,
        autoAlpha: 0,
        duration: 0.9,
        delay: i * 0.08,
        ease: "power3.out",
        scrollTrigger: { trigger: item, start: "top 92%", once: true },
      });
    });

    gsap.from(".footer-grid .footer-col", {
      y: 26,
      autoAlpha: 0,
      duration: 0.8,
      stagger: 0.1,
      ease: "power3.out",
      scrollTrigger: { trigger: ".footer-grid", start: "top 94%", once: true },
    });

    gsap.from(".timezone-row", {
      y: 16,
      autoAlpha: 0,
      duration: 0.8,
      ease: "power3.out",
      scrollTrigger: { trigger: ".cta-block", start: "top 80%", once: true },
    });
  }

  /* ---------------- ASCII logo ----------------
     Original idea: sample a logo into particle targets,
     idle drift + magnetic repel + click to shatter & reform.
      Here we sample the word ABBOS drawn offscreen,
     so the code stays clean and dependency-free. */

  class AsciiLogo {
    constructor(wrap, canvas) {
      this.wrap = wrap;
      this.canvas = canvas;
      this.ctx = canvas.getContext("2d");
      this.mouse = { x: 0, y: 0, active: false };
      this.particles = [];
      // idle | fall | form | spread | done — same 5 phases as original
      this.phase = "idle";
      this.dpr = 1;
      this.fontSize = 10;
      this.floorY = 0;
      this.raf = 0;
      this.visible = true;
      this.revealed = false;
      this.fallStart = 0;
      this.formStart = 0;
      this.spreadStart = 0;
      // Exact original timings / physics (letter size 10)
      this.JITTER = 140;
      this.STEP = 10;
      this.FALL_MS = 1200;
      this.FORM_MS = 1100;
      this.GRAVITY = 0.7;
      this.MAG_S = 28;
      this.MAG_R = 120;
      this.MAG_RET = 0.08;
      this.fitScale = 1;
      this.bounds = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
    }

    randChar() {
      return ASCII_SET[(Math.random() * ASCII_SET.length) | 0];
    }

    snap(v) {
      const unit = Math.max(1, this.fontSize);
      return Math.round(v / unit) * unit;
    }

    updateBounds() {
      const edge = Math.ceil(this.fontSize * 0.5);
      this.bounds.minX = edge;
      this.bounds.minY = edge;
      this.bounds.maxX = this.canvas.width - edge - this.fontSize * 0.5;
      this.bounds.maxY = this.canvas.height - edge - this.fontSize;
      this.floorY = this.bounds.maxY;
      this.logoBounds = {
        minX: edge,
        minY: Math.ceil(this.fontSize * 0.75),
        maxX: this.canvas.width - edge - this.fontSize * 0.5,
        maxY: this.canvas.height - Math.ceil(this.fontSize * 0.75) - this.fontSize,
      };
    }

    clamp(p) {
      p.x = Math.max(this.bounds.minX, Math.min(this.bounds.maxX, p.x));
      p.y = Math.max(this.bounds.minY, Math.min(this.bounds.maxY, p.y));
    }

    sampleTargets() {
      // 1. Draw word offscreen — stretched to the same full width
      // as the original logo (ABBOS has fewer letters, so fill the width).
      const LABEL = "ABBOS";
      const off = document.createElement("canvas");
      off.width = 1200;
      off.height = 240;
      const o = off.getContext("2d", { willReadFrequently: true });
      o.clearRect(0, 0, off.width, off.height);
      o.fillStyle = "#fff";
      o.textAlign = "center";
      o.textBaseline = "middle";
      // Same family as site — Neue Haas Grotesk.
      o.font = '700 168px "Neue Haas Grotesk Text Pro 55 Roman", Arial, sans-serif';
      const measured = o.measureText(LABEL).width || 1;
      const stretchX = (off.width * 0.94) / measured;
      o.save();
      o.translate(off.width / 2, off.height / 2 + 8);
      o.scale(stretchX, 1);
      o.fillText(LABEL, 0, 0);
      o.restore();

      const data = o.getImageData(0, 0, off.width, off.height).data;
      const step = this.STEP;
      const raw = [];
      for (let y = 0; y < off.height; y += step) {
        for (let x = 0; x < off.width; x += step) {
          if (data[(y * off.width + x) * 4 + 3] > 50) raw.push({ x, y });
        }
      }

      // 2. Fit into display canvas, keep density identical on any screen.
      const fitW = this.logoBounds.maxX - this.logoBounds.minX;
      const fitH = this.logoBounds.maxY - this.logoBounds.minY;
      this.fitScale = Math.min(fitW / off.width, fitH / off.height);
      const dispW = off.width * this.fitScale;
      const dispH = off.height * this.fitScale;
      const ox = this.logoBounds.minX + (fitW - dispW) / 2;
      const oy = this.logoBounds.minY + (fitH - dispH) / 2;

      this.fontSize = Math.max(4, Math.round(step * this.fitScale));
      this.updateBounds();
      const unit = this.fontSize;

      return raw.map((p) => ({
        tx: Math.round((ox + p.x * this.fitScale) / unit) * unit,
        ty: Math.round((oy + p.y * this.fitScale) / unit) * unit,
      }));
    }

    nearPos(t) {
      const jitter = this.JITTER * this.fitScale;
      const angle = Math.random() * Math.PI * 2;
      const maxDist = Math.min(
        jitter,
        t.tx - this.bounds.minX,
        this.bounds.maxX - t.tx,
        t.ty - this.bounds.minY,
        this.bounds.maxY - t.ty
      );
      const dist = Math.random() * Math.max(0, maxDist);
      return { x: t.tx + Math.cos(angle) * dist, y: t.ty + Math.sin(angle) * dist };
    }

    spawn(targets) {
      // Default state is the formed ABBOS logo — particles start on target.
      return targets.map((t, i) => {
        return {
          tx: t.tx,
          ty: t.ty,
          x: t.tx,
          y: t.ty,
          vx: (Math.random() - 0.5) * 0.3 * this.dpr,
          vy: (Math.random() - 0.5) * 0.3 * this.dpr,
          hx: t.tx,
          hy: t.ty,
          fx: 0,
          fy: 0,
          sx: 0,
          sy: 0,
          char: this.randChar(),
          delay: (i % 20) / 20,
          done: false,
        };
      });
    }

    resize() {
      const rect = this.wrap.getBoundingClientRect();
      this.dpr = Math.min(window.devicePixelRatio || 1, 2);
      const w = Math.max(1, Math.round(rect.width * this.dpr));
      const h = Math.max(1, Math.round(rect.height * this.dpr));
      if (this.canvas.width !== w || this.canvas.height !== h) {
        this.canvas.width = w;
        this.canvas.height = h;
      }
      this.fontSize = Math.max(5, Math.round(this.canvas.height / 14));
      this.updateBounds();
    }

    rebuild() {
      this.resize();
      const targets = this.sampleTargets();
      this.particles = this.spawn(targets);
      // Default view is the formed ABBOS logo (skip the entrance fall).
      this.phase = "done";
      this.revealed = true;
      this.startLoop();
    }

    scramble(p, rate = 0.015) {
      if (Math.random() < rate) p.char = this.randChar();
    }

    updateIdle(p, t) {
      p.x += p.vx + Math.sin(t * 0.001 + p.y) * 0.03 * this.dpr;
      p.y += p.vy + Math.cos(t * 0.001 + p.x) * 0.03 * this.dpr;
      this.clamp(p);
      this.scramble(p);
    }

    applyMagnetic(p) {
      if (this.mouse.active) {
        const dx = this.mouse.x - p.x;
        const dy = this.mouse.y - p.y;
        const dist = Math.hypot(dx, dy) || 1;
        const radius = this.MAG_R * this.dpr;
        if (dist < radius) {
          const k = 1 - dist / radius;
          const force = k * k * this.MAG_S;
          p.x -= (dx / dist) * force;
          p.y -= (dy / dist) * force;
          if (k > 0.65 && Math.random() < 0.06) p.char = this.randChar();
        }
      }
      p.x += (p.hx - p.x) * this.MAG_RET;
      p.y += (p.hy - p.y) * this.MAG_RET;
      this.clamp(p);
    }

    updateFall(p) {
      p.vy += this.GRAVITY * this.dpr;
      p.x += p.vx;
      p.y += p.vy;
      if (p.y >= this.floorY) {
        p.y = this.floorY;
        p.vy *= -0.25;
        p.vx *= 0.8;
      }
      this.clamp(p);
    }

    startSpread(now) {
      this.phase = "spread";
      this.spreadStart = now;
      this.particles.forEach((p) => {
        p.fx = p.x;
        p.fy = p.y;
        const pos = this.nearPos(p);
        p.sx = pos.x;
        p.sy = pos.y;
        p.vx = (Math.random() - 0.5) * 0.3 * this.dpr;
        p.vy = (Math.random() - 0.5) * 0.3 * this.dpr;
        p.hx = p.sx;
        p.hy = p.sy;
        p.done = false;
      });
    }

    updateSpread(p, now) {
      const delay = p.delay * this.FORM_MS * 0.5;
      const t = Math.max(0, Math.min(1, (now - this.spreadStart - delay) / this.FORM_MS));
      const e = 1 - Math.pow(1 - t, 3);
      p.x = p.fx + (p.sx - p.fx) * e;
      p.y = p.fy + (p.sy - p.fy) * e;
      this.clamp(p);
      if (t >= 1) p.done = true;
      this.scramble(p, 0.012);
    }

    startForm(now) {
      this.phase = "form";
      this.formStart = now;
      this.particles.forEach((p) => {
        p.fx = p.x;
        p.fy = p.y;
        p.done = false;
      });
    }

    updateForm(p, now) {
      const delay = p.delay * this.FORM_MS * 0.5;
      const t = Math.max(0, Math.min(1, (now - this.formStart - delay) / this.FORM_MS));
      const e = 1 - Math.pow(1 - t, 3);
      p.x = p.fx + (p.tx - p.fx) * e;
      p.y = p.fy + (p.ty - p.fy) * e;
      if (t >= 0.98) {
        p.x = this.snap(p.tx);
        p.y = this.snap(p.ty);
      }
      this.clamp(p);
      if (t >= 1) p.done = true;
      this.scramble(p, 0.012);
    }

    draw(now) {
      this.raf = 0;
      if (!this.visible) return;

      const { ctx } = this;
      ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
      ctx.fillStyle = "#fff";
      ctx.font = `${this.fontSize}px Menlo, Monaco, "Courier New", monospace`;
      ctx.textBaseline = "top";

      for (const p of this.particles) {
        if (this.phase === "idle") {
          this.updateIdle(p, now);
          this.applyMagnetic(p);
        } else if (this.phase === "fall") this.updateFall(p);
        else if (this.phase === "form") this.updateForm(p, now);
        else if (this.phase === "spread") this.updateSpread(p, now);
        else if (this.phase === "done") {
          this.applyMagnetic(p);
          this.scramble(p);
        }
        ctx.fillText(p.char, p.x, p.y);
      }

      if (this.phase === "form" && this.particles.every((p) => p.done)) {
        this.phase = "done";
        this.particles.forEach((p) => {
          p.x = this.snap(p.tx);
          p.y = this.snap(p.ty);
          p.hx = p.tx;
          p.hy = p.ty;
        });
      }

      if (this.phase === "spread" && this.particles.every((p) => p.done)) {
        this.phase = "idle";
      }

      if (this.phase === "fall" && now - this.fallStart >= this.FALL_MS) {
        this.startForm(now);
      }

      this.raf = requestAnimationFrame((t) => this.draw(t));
    }

    startLoop() {
      if (!this.raf && this.visible) this.raf = requestAnimationFrame((t) => this.draw(t));
    }

    stopLoop() {
      if (this.raf) cancelAnimationFrame(this.raf);
      this.raf = 0;
    }

    triggerFall() {
      if (this.phase !== "idle") return false;
      this.phase = "fall";
      this.fallStart = performance.now();
      this.particles.forEach((p) => {
        p.vy += (Math.random() * 2 + 0.5) * this.dpr;
        p.vx += (Math.random() - 0.5) * 5 * this.dpr;
      });
      this.startLoop();
      return true;
    }

    onClick() {
      if (this.phase !== "idle" && this.phase !== "done") return;
      // Click 1 (idle): fall → form → done. Click 2 (done): spread → idle.
      if (this.phase === "done") {
        this.startSpread(performance.now());
        this.startLoop();
        return;
      }
      this.triggerFall();
    }

    bind() {
      this.wrap.addEventListener("click", () => this.onClick());

      this.canvas.addEventListener("mousemove", (e) => {
        const r = this.canvas.getBoundingClientRect();
        this.mouse.x = (e.clientX - r.left) * this.dpr;
        this.mouse.y = (e.clientY - r.top) * this.dpr;
        this.mouse.active = true;
      });
      this.canvas.addEventListener("mouseleave", () => (this.mouse.active = false));

      let timer = 0;
      const onResize = () => {
        clearTimeout(timer);
        timer = setTimeout(() => this.rebuild(), 100);
      };
      if ("ResizeObserver" in window) new ResizeObserver(onResize).observe(this.wrap);
      else window.addEventListener("resize", onResize);

      const io = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            this.visible = entry.isIntersecting;
            if (this.visible) {
              this.startLoop();
              if (!this.revealed && entry.intersectionRatio >= 0.8) {
                this.revealed = true;
                if (!prefersReduced) this.triggerFall();
                else this.phase = "done";
              }
            } else this.stopLoop();
          });
        },
        { threshold: [0, 0.25, 0.5, 0.75, 0.8] }
      );
      io.observe(this.wrap);

      if (document.fonts?.ready) document.fonts.ready.then(() => this.rebuild());
    }

    init() {
      this.rebuild();
      this.bind();
    }
  }

  function initAscii() {
    const wrap = document.getElementById("asciiWrap");
    const canvas = document.getElementById("asciiCanvas");
    if (!wrap || !canvas) return;
    new AsciiLogo(wrap, canvas).init();
  }

  /* ---------------- Boot ---------------- */

  function boot() {
    initClocks();
    initScrambleHeadline();
    initFlickerHover();
    initReveals();
    initAscii();
    ScrollTrigger.refresh();
  }

  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
  else boot();
})();
````
