Skip to content

ASCII Density Nav

A nav whose active link is marked by a band of ASCII density: characters thicken through a ramp under the active link and flow to the next one on a spring, its peak in signal.

FreeDither & ASCIIminimaleditorial
Preview

Choose a link

The band flows to it.

Props

No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from "react";

/**
 * AsciiDensityNav — a nav whose active link is marked by a band of ASCII density.
 *
 * Under the links runs a line of mono characters that thicken through a
 * ramp (. · : - = + * # % @) around the active link, densest under its
 * centre; choose another and the band flows there on a spring, its peak in
 * signal, and hover raises a smaller swell. Drawn a frame at a time only
 * while something moves. Controlled or not; folds into a menu when narrow.
 *
 * Part of the Dither & ASCII kit: one ink, paper #f3f0e6, on a warm black
 * ground #0b0b0d, with vermillion #ff4f2b as a second ink inside the
 * pictures and for a live state. Cells snap to whole device pixels;
 * scenes dither in linear light, photographs on a gentler curve. Funnel Display through
 * var(--font-funnel) for display over Geist; Geist Mono for every glyph.
 * Respects prefers-reduced-motion. No dependencies beyond React.
 * Paste it as its own file: it repeats the kit's small helpers, which would
 * clash in one module. Load Funnel Display with next/font (variable:
 * "--font-funnel") on a parent, or from Google Fonts or @fontsource.
 * Needs Tailwind v4 (on v3.4, add the @tailwindcss/container-queries plugin).
 */

const SANS = 'var(--font-sans, "Geist", "Inter", ui-sans-serif, system-ui, sans-serif)';

const MONO = 'var(--font-mono, "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace)';

const DISPLAY = 'var(--font-funnel, "Funnel Display", var(--font-sans, "Geist"), ui-sans-serif, system-ui, sans-serif)';

/** One ink on a warm black ground, and one signal colour. */
const SHEET = {
  ground: "#0b0b0d",
  panel: "#121214",
  raised: "#19191c",
  paper: "#f3f0e6",
  ink: "rgba(243,240,230,0.92)",
  ink2: "rgba(243,240,230,0.6)",
  ink3: "rgba(243,240,230,0.38)",
  line: "rgba(243,240,230,0.08)",
  line2: "rgba(243,240,230,0.16)",
  signal: "#ff4f2b",
} as const;

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */
function safeHref(raw: string): string {
  const v = raw.replace(/[\t\n\r]/g, "").trim();
  if (!v || v.includes("\\")) return "#";
  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
  if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
  return "#";
}

function subscribeMotion(cb: () => void) {
  const m = window.matchMedia("(prefers-reduced-motion: reduce)");
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
}

/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeMotion,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );
}

/** A spring for one number; returns the new value and velocity. */
function spring(x: number, v: number, target: number, dt: number, k = 260, c = 26): [number, number] {
  const steps = dt > 1 / 60 ? 2 : 1;
  const h = dt / steps;
  for (let i = 0; i < steps; i++) {
    v += (k * (target - x) - c * v) * h;
    x += v * h;
  }
  return [x, v];
}

// Each part carries only the CSS it needs, so any one of them works on its own.
// Nothing is interpolated into these sheets; colours arrive as CSS variables.
// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.
const FOCUS_CSS = ".da-scope :focus-visible,.da-scope:focus-visible{outline:2px solid var(--da-ring,#ff4f2b)!important;outline-offset:3px;border-radius:revert-layer}";

/** The 8x8 Bayer index of a cell (0-63). Recursive, so every 2x2 and 4x4 block is itself in order. */
function bayer8(x: number, y: number): number {
  let v = 0;
  for (let bit = 0; bit < 3; bit++) {
    const xb = (x >> bit) & 1;
    const yb = (y >> bit) & 1;
    v |= (((xb ^ yb) << 1) | yb) << (2 * (2 - bit));
  }
  return v;
}

const daMaskCache = new Map<number, string>();

/** A mask tile with the first `level` of the 64 Bayer cells opaque: 0 shows nothing, 64 shows everything. */
function daBayerMask(level: number): string {
  const k = clamp(Math.round(level), 0, 64);
  const hit = daMaskCache.get(k);
  if (hit) return hit;
  let d = "";
  for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) if (bayer8(x, y) < k) d += `M${x} ${y}h1v1h-1z`;
  const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' shape-rendering='crispEdges'><path d='${d}'/></svg>`;
  const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
  daMaskCache.set(k, url);
  return url;
}

/** The style that masks an element to a Bayer level, tiled at `px` CSS pixels a cell. */
function daMaskStyle(level: number, px: number): CSSProperties {
  const m = daBayerMask(level);
  const s = `${px * 8}px ${px * 8}px`;
  return { maskImage: m, WebkitMaskImage: m, maskSize: s, WebkitMaskSize: s, maskRepeat: "repeat", WebkitMaskRepeat: "repeat" };
}

/**
 * A cell size in CSS pixels snapped to whole device pixels for this element
 * (the screen's pixel ratio times any CSS scale on the way), so every dither
 * cell lands on the pixel grid and never blurs into grey.
 */
function useSnappedCell(ref: RefObject<HTMLElement | null>, cell: number): number {
  const [px, setPx] = useState(cell);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let raf = 0;
    const measure = () => {
      const k = el.offsetWidth ? el.getBoundingClientRect().width / el.offsetWidth : 1;
      const dev = (window.devicePixelRatio || 1) * (k || 1);
      setPx(Math.max(1, Math.round(cell * dev)) / dev);
    };
    const ro = new ResizeObserver(() => {
      measure();
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(measure);
    });
    ro.observe(el);
    return () => {
      ro.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [ref, cell]);
  return px;
}

const DA_BUTTON_INK = {
  signal: { fill: SHEET.signal, text: SHEET.ground, border: SHEET.signal, label: SHEET.paper, rest: 5 },
  paper: { fill: SHEET.paper, text: SHEET.ground, border: "rgba(243,240,230,0.5)", label: SHEET.paper, rest: 5 },
  ghost: { fill: SHEET.paper, text: SHEET.ground, border: SHEET.line2, label: SHEET.ink, rest: 0 },
} as const;

export type DaButtonVariant = keyof typeof DA_BUTTON_INK;

/**
 * BayerFillButton — a button whose fill prints in, cell by cell.
 *
 * At rest a few cells of its ink sit in the face. Hover or focus it and the
 * fill develops through the 8x8 Bayer order to solid in about 260ms (and
 * back out in 360ms), while the label inverts exactly where each cell
 * lands, because the inverted label lives inside the masked fill. Press it
 * and the grid halves to a finer screen and the face drops a pixel. Signal,
 * paper and ghost inks; `lit` holds it filled (a chosen option). CSS masks
 * and a few lines of animation, no canvas.
 */
export function BayerFillButton({
  children,
  href,
  onClick,
  type = "button",
  variant = "paper",
  size = "lg",
  lit = false,
  disabled = false,
  ariaLabel,
  ariaPressed,
  className = "",
}: {
  children: ReactNode;
  href?: string;
  onClick?: () => void;
  type?: "button" | "submit";
  variant?: DaButtonVariant;
  size?: "lg" | "md" | "sm";
  /** Hold it filled, for a chosen option. */
  lit?: boolean;
  disabled?: boolean;
  ariaLabel?: string;
  ariaPressed?: boolean;
  className?: string;
}) {
  const ink = DA_BUTTON_INK[variant] ?? DA_BUTTON_INK.signal;
  const rootRef = useRef<HTMLElement | null>(null);
  const fillRef = useRef<HTMLSpanElement>(null);
  const reduced = useReducedMotion();
  const px = useSnappedCell(rootRef, 2);
  const state = useRef({ level: lit ? 64 : ink.rest, hover: false, focus: false, down: false, raf: 0, last: 0 });
  const [down, setDown] = useState(false);
  const litRef = useRef(lit);
  // The mask React renders never changes after the first render: the loop below owns it from then on.
  const [firstMask] = useState(() => daMaskStyle(lit ? 64 : ink.rest, 2));

  // One small loop: the level walks toward its target (in 260ms, out 360ms) and the mask follows.
  const run = () => {
    const s = state.current;
    if (s.raf) return;
    s.last = 0;
    const tick = (now: number) => {
      s.raf = 0;
      const el = fillRef.current;
      if (!el) return;
      const dt = s.last ? Math.min(0.05, (now - s.last) / 1000) : 1 / 60;
      s.last = now;
      const want = litRef.current || s.hover || s.focus || s.down ? 64 : ink.rest;
      const rate = want > s.level ? 64 / 0.26 : 64 / 0.36;
      s.level = reduced ? want : want > s.level ? Math.min(want, s.level + rate * dt) : Math.max(want, s.level - rate * dt);
      Object.assign(el.style, daMaskStyle(s.level, s.down ? px / 2 : px));
      if (s.level !== want) s.raf = requestAnimationFrame(tick);
    };
    s.raf = requestAnimationFrame(tick);
  };
  useEffect(() => {
    litRef.current = lit;
    run();
  });
  // Cancel on unmount and clear the handle, or a remount (StrictMode does one) would never start the loop again.
  useEffect(() => {
    const s = state.current;
    return () => {
      cancelAnimationFrame(s.raf);
      s.raf = 0;
    };
  }, []);

  const set = (patch: Partial<{ hover: boolean; focus: boolean; down: boolean }>) => {
    Object.assign(state.current, patch);
    if (patch.down !== undefined) setDown(patch.down);
    run();
  };
  const pad = size === "sm" ? "h-8 px-3 text-[12px]" : size === "md" ? "h-10 px-4 text-[13px]" : "h-12 px-6 text-[14px]";
  const common = {
    className:
      "da-scope relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-[10px] font-medium no-underline outline-none transition-transform duration-150 " +
      pad +
      (disabled ? " pointer-events-none opacity-40 " : " ") +
      className,
    style: {
      border: `1px solid ${ink.border}`,
      color: ink.label,
      background: SHEET.ground,
      fontFamily: SANS,
      letterSpacing: "0.005em",
      transform: down ? "translateY(1px)" : undefined,
    } as CSSProperties,
    "aria-label": ariaLabel,
    onPointerEnter: () => set({ hover: true }),
    onPointerLeave: () => set({ hover: false, down: false }),
    onPointerDown: () => set({ down: true }),
    onPointerUp: () => set({ down: false }),
    onPointerCancel: () => set({ down: false }),
    onFocus: () => set({ focus: true }),
    onBlur: () => set({ focus: false, down: false }),
    // Enter activates both; Space activates only a button (a link scrolls on Space), so only then does it press.
    onKeyDown: (e: ReactKeyboardEvent) => {
      if (e.key === "Enter" || (e.key === " " && !href)) set({ down: true });
    },
    onKeyUp: () => set({ down: false }),
  };
  const face = (
    <>
      <span className="relative whitespace-nowrap">{children}</span>
      <span
        ref={fillRef}
        aria-hidden
        className={"pointer-events-none absolute inset-0 flex items-center justify-center whitespace-nowrap " + pad}
        style={{ background: ink.fill, color: ink.text, ...firstMask }}
      >
        {children}
      </span>
    </>
  );
  if (href) {
    return (
      <>
        <style>{FOCUS_CSS}</style>
        <a ref={(el) => void (rootRef.current = el)} href={safeHref(href)} onClick={onClick} {...common}>
          {face}
        </a>
      </>
    );
  }
  return (
    <>
      <style>{FOCUS_CSS}</style>
      <button ref={(el) => void (rootRef.current = el)} type={type} onClick={onClick} disabled={disabled} aria-pressed={ariaPressed} {...common}>
        {face}
      </button>
    </>
  );
}

export type DaLink = { label: string; href: string };

/** From empty to full: the ramp the density band is set in. */
const DA_RAMP = " .·:-=+*#%@";

/**
 * AsciiDensityNav — a nav whose active link is marked by a band of ASCII
 * density that flows between links.
 *
 * Under the links runs a line of mono characters. Around the active link
 * the characters thicken through a ramp (. · : - = + * # % @), densest
 * under its centre, and when another link is chosen the band flows there on
 * a spring, its peak in signal; hovering or focusing a link raises a smaller
 * swell under it. The characters are real text drawn a frame at a time only
 * while something moves. Controlled or not; folds into a menu on narrow
 * containers.
 */
export function AsciiDensityNav({
  brand = "Dotplane",
  links,
  cta,
  active,
  defaultActive,
  onNavigate,
  className = "",
  style,
}: {
  brand?: string;
  links: DaLink[];
  cta?: DaLink | null;
  /** The active link's href, controlled. */
  active?: string;
  defaultActive?: string;
  onNavigate?: (href: string) => void;
  className?: string;
  style?: CSSProperties;
}) {
  const [own, setOwn] = useState(defaultActive ?? links[0]?.href ?? "");
  const current = active ?? own;
  const [open, setOpen] = useState(false);
  const menuId = useId();
  const reduced = useReducedMotion();
  const rowRef = useRef<HTMLDivElement>(null);
  const baseRef = useRef<HTMLSpanElement>(null);
  const hotRef = useRef<HTMLSpanElement>(null);
  const menuBtn = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);
  const band = useRef({ x: -1, v: 0, w: 60, hx: -1, hw: 0, ha: 0, raf: 0, last: 0, chars: 0, cw: 7.2 });
  const [hover, setHover] = useState(-1);
  const idx = Math.max(0, links.findIndex((l) => l.href === current));

  // Draws the band: each character's density is a Gaussian around the active link, plus the hover swell.
  useEffect(() => {
    const row = rowRef.current;
    const base = baseRef.current;
    const hot = hotRef.current;
    if (!row || !base || !hot) return;
    const b = band.current;
    const target = () => {
      const a = linkRefs.current[idx];
      const h = hover >= 0 ? linkRefs.current[hover] : null;
      const r0 = row.getBoundingClientRect();
      const k = row.offsetWidth ? r0.width / row.offsetWidth : 1;
      const at = (el: HTMLElement | null) => {
        if (!el) return null;
        const r = el.getBoundingClientRect();
        return { x: (r.left - r0.left + r.width / 2) / k, w: r.width / k };
      };
      return { a: at(a), h: at(h) };
    };
    const draw = () => {
      let s = "";
      let peak = "";
      for (let i = 0; i < b.chars; i++) {
        const x = (i + 0.5) * b.cw;
        const da = (x - b.x) / (b.w * 0.42);
        const dh = (x - b.hx) / Math.max(1, b.hw * 0.3);
        const v = Math.min(1, 0.06 + Math.exp(-da * da) + b.ha * 0.55 * Math.exp(-dh * dh));
        const c = DA_RAMP[Math.min(DA_RAMP.length - 1, Math.floor(v * (DA_RAMP.length - 0.01)))];
        s += c;
        peak += v > 0.86 ? c : " ";
      }
      base.textContent = s;
      hot.textContent = peak;
    };
    const tick = (now: number) => {
      b.raf = 0;
      const dt = b.last ? Math.min(0.05, (now - b.last) / 1000) : 1 / 60;
      b.last = now;
      const t = target();
      let busy = false;
      if (t.a) {
        if (b.x < 0 || reduced) {
          b.x = t.a.x;
          b.w = t.a.w;
        } else {
          [b.x, b.v] = spring(b.x, b.v, t.a.x, dt, 170, 22);
          b.w += (t.a.w - b.w) * (1 - Math.exp(-dt * 10));
          busy = Math.abs(t.a.x - b.x) > 0.3 || Math.abs(b.v) > 0.5;
        }
      }
      const want = t.h ? 1 : 0;
      if (t.h) {
        b.hx = t.h.x;
        b.hw = t.h.w;
      }
      b.ha = reduced ? want : b.ha + (want - b.ha) * (1 - Math.exp(-dt * 12));
      if (Math.abs(want - b.ha) > 0.01) busy = true;
      draw();
      if (busy) b.raf = requestAnimationFrame(tick);
      else b.last = 0;
    };
    const measure = () => {
      const probe = document.createElement("span");
      probe.textContent = "0000000000";
      probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;font:inherit;letter-spacing:inherit";
      base.parentElement?.appendChild(probe);
      b.cw = probe.getBoundingClientRect().width / 10 / (row.offsetWidth ? row.getBoundingClientRect().width / row.offsetWidth : 1) || 7.2;
      probe.remove();
      b.chars = Math.max(0, Math.floor(row.offsetWidth / b.cw));
      if (!b.raf) b.raf = requestAnimationFrame(tick);
    };
    const ro = new ResizeObserver(measure);
    ro.observe(row);
    if (!b.raf) b.raf = requestAnimationFrame(tick);
    return () => {
      ro.disconnect();
      cancelAnimationFrame(b.raf);
      b.raf = 0;
    };
  }, [idx, hover, reduced, links.length]);

  // The menu closes on Escape (focus back to its button) and on a click outside.
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setOpen(false);
        menuBtn.current?.focus();
      }
    };
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (!panelRef.current?.contains(t) && !menuBtn.current?.contains(t)) setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, [open]);

  const go = (href: string) => {
    if (active === undefined) setOwn(href);
    onNavigate?.(href);
    setOpen(false);
  };

  return (
    <header className={"da-scope relative w-full @container " + className} style={{ fontFamily: SANS, color: SHEET.ink, ...style }}>
      <style>{FOCUS_CSS}</style>
      <nav
        aria-label="Main"
        className="flex h-16 items-center gap-6 px-5 @3xl:px-8"
        style={{ background: "rgba(11,11,13,0.82)", backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)", borderBottom: `1px solid ${SHEET.line}` }}
      >
        <a href={safeHref(links[0]?.href ?? "#")} className="flex shrink-0 items-center gap-2.5 no-underline" style={{ color: SHEET.paper }} onClick={() => go(links[0]?.href ?? "#")}>
          <span aria-hidden className="grid h-[18px] w-[18px] grid-cols-3 gap-[2px]">
            {[1, 0, 1, 0, 1, 0, 1, 0, 1].map((on, i) => (
              <span key={i} style={{ background: on ? (i === 4 ? SHEET.signal : SHEET.paper) : "transparent" }} />
            ))}
          </span>
          <span className="text-[16px] font-semibold" style={{ fontFamily: DISPLAY, letterSpacing: "-0.02em" }}>
            {brand}
          </span>
        </a>
        <div ref={rowRef} className="relative mx-auto hidden h-full min-w-0 flex-1 @3xl:block">
          <ul className="m-0 flex h-full list-none items-center justify-center gap-1 p-0">
            {links.map((l, i) => (
              <li key={l.href}>
                <a
                  ref={(el) => void (linkRefs.current[i] = el)}
                  href={safeHref(l.href)}
                  aria-current={l.href === current ? "page" : undefined}
                  onClick={() => go(l.href)}
                  onPointerEnter={() => setHover(i)}
                  onPointerLeave={() => setHover(-1)}
                  onFocus={() => setHover(i)}
                  onBlur={() => setHover(-1)}
                  className="relative block rounded-[8px] px-3.5 py-2 text-[13.5px] no-underline transition-colors duration-200"
                  style={{ color: l.href === current ? SHEET.paper : SHEET.ink2 }}
                >
                  {l.label}
                </a>
              </li>
            ))}
          </ul>
          <span
            aria-hidden
            className="pointer-events-none absolute inset-x-0 bottom-[7px] block overflow-hidden whitespace-pre text-[11px] leading-none"
            style={{ fontFamily: MONO, letterSpacing: "0.04em", color: SHEET.ink3 }}
          >
            <span ref={baseRef} className="block" />
            <span ref={hotRef} className="absolute inset-0 block" style={{ color: SHEET.signal }} />
          </span>
        </div>
        <div className="ml-auto flex shrink-0 items-center gap-2 @3xl:ml-0">
          {cta ? (
            <BayerFillButton href={cta.href} size="md" variant="paper" onClick={() => go(cta.href)}>
              {cta.label}
            </BayerFillButton>
          ) : null}
          <button
            ref={menuBtn}
            type="button"
            aria-expanded={open}
            aria-controls={menuId}
            onClick={() => setOpen((v) => !v)}
            className="grid h-10 w-10 place-items-center rounded-[10px] @3xl:hidden"
            style={{ border: `1px solid ${SHEET.line2}`, background: "transparent", color: SHEET.paper }}
          >
            <span className="sr-only">Menu</span>
            <span aria-hidden className="text-[13px]" style={{ fontFamily: MONO }}>
              {open ? "×" : "≡"}
            </span>
          </button>
        </div>
      </nav>
      {open ? (
        <div ref={panelRef} id={menuId} className="absolute inset-x-3 top-[68px] z-20 rounded-[14px] p-2 @3xl:hidden" style={{ background: SHEET.panel, border: `1px solid ${SHEET.line2}` }}>
          <ul className="m-0 list-none p-0">
            {links.map((l) => (
              <li key={l.href}>
                <a
                  href={safeHref(l.href)}
                  aria-current={l.href === current ? "page" : undefined}
                  onClick={() => go(l.href)}
                  className="flex items-center justify-between rounded-[10px] px-3 py-2.5 text-[15px] no-underline"
                  style={{ color: l.href === current ? SHEET.paper : SHEET.ink2 }}
                >
                  {l.label}
                  <span aria-hidden className="text-[11px]" style={{ fontFamily: MONO, color: l.href === current ? SHEET.signal : SHEET.ink3 }}>
                    {l.href === current ? "#%@" : ".:-"}
                  </span>
                </a>
              </li>
            ))}
          </ul>
        </div>
      ) : null}
    </header>
  );
}
Notes

About this component

A navigation bar whose active state is drawn in type. Under the links runs a single line of mono characters; around the active link they thicken through a ramp from a dot to an at-sign, densest under its centre, and fall away to a faint dotted rule. Choose another link and the band flows there on a spring with a little overshoot, its peak characters lit in signal as it travels; hovering or focusing a link raises a smaller swell under it. The characters are real text written into one line, redrawn only while something moves. The active link can be controlled (the kit's site drives it from a scroll spy), and on a narrow container the links fold into a menu.

Curator’s note

The Dither & ASCII kit's navigation (slot 5), cut from dither-ascii-site, where a scroll spy drives it. Free: a line of real text redrawn a frame at a time only while it moves.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

New

A nav whose single hairline stretches toward the next link and settles under it: its leading edge runs ahead on a quick spring while its trailing edge follows on a slower one, and the active label steps up a weight without shifting.

navigationnavbarunderline
New

Axis Flow Nav

components

A nav whose links are one flush line: the active link is loud, the rest condensed, and choosing another makes the width flow to it letter by letter while the nav never changes size.

navigationnavbarvariable font

A floating nav whose current-link indicator is a capsule of mercury on two springs: it stretches thin as it pours to the next link, snaps back round, reaches toward the link you hover, and turns the labels it covers dark.

navigationnavbarindicator