Skip to content

Levitating Link Nav

A dark glass nav bar whose link labels hang in the air above a lit shelf, each over its own soft shadow; the link you point at, and the one for the section in view, lands.

FreeAttentionminimal
Preview

Section 01

Studio

Point at a link and it lands on the shelf.

Section 02

Rooms

The section in view keeps its link down.

Section 03

Visit

The rest hang in the air, each on its own slow period.

Section 04

Plans

Below 768px the links move into a sheet.

Props

5
Bob
Land the section in view
Button
No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, RefObject } from "react";

/**
 * LevitatingLinkNav — a dark glass bar whose link labels hang in the air above a lit shelf.
 *
 * Each label floats a few pixels above the shelf over its own soft shadow,
 * and bobs on its own slow period (9.7 to 16.7 s), so the row never moves
 * in step. The shadow does the work: the higher a label, the wider, softer
 * and fainter its shadow. Point at a link or tab to it and it lands (a
 * critically damped spring, about 260 ms): it drops onto the shelf and its
 * shadow tightens into a contact line. With track on, the link whose
 * section is in view stays landed and is marked aria-current. Over the
 * first 120px of scroll the bar tightens and its glass fills in, read from
 * whatever scrolls it. Below a 768px container the links move into a sheet
 * that closes on Escape or an outside tap. Reduced motion holds the labels
 * still; the loop runs only while something moves and the bar is on screen.
 *
 * Part of the Attention kit: a style of anomalies, familiar things made wrong
 * (fish in dry air, a stone that breaks upward, a shadow that runs toward its
 * lamp). One subject, one motivated light, one broken rule, and everything
 * else calm. Cut byte for byte from the kit's one site (AttentionSite, a
 * demo studio called Aerium). Paste it as its own file: it carries its own
 * copy of every helper it uses and has no dependencies.
 */

/**
 * The scrolling box an element lives in: the nearest ancestor that actually
 * scrolls, or null for the window. Resolved when asked, because a box only
 * becomes a scroller once its content is taller than it.
 */
function aScrollParent(el: Element | null): HTMLElement | null {
  let n = el?.parentElement ?? null;
  while (n && n !== document.body && n !== document.documentElement) {
    const oy = getComputedStyle(n).overflowY;
    if ((oy === "auto" || oy === "scroll" || oy === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
    n = n.parentElement;
  }
  return null;
}

interface AFrame {
  /** The element's box, in viewport pixels. */
  rect: DOMRect;
  /** The visible part of whatever scrolls it: the scroller's box, or the window. */
  view: { top: number; height: number; left: number; width: number };
}

/**
 * Run `apply` with the element's position whenever anything scrolls or
 * resizes, at most once per frame. One capture-phase scroll listener hears
 * every scroller, so the same piece works on a page and inside a scrolling
 * preview. `apply` writes to the DOM; nothing here re-renders React.
 */
function useAScrollFrame<T extends HTMLElement>(ref: RefObject<T | null>, apply: (f: AFrame) => void) {
  const applyRef = useRef(apply);
  useEffect(() => {
    applyRef.current = apply;
  });
  useEffect(() => {
    let raf = 0;
    const run = () => {
      raf = 0;
      const el = ref.current;
      if (!el) return;
      const sp = aScrollParent(el);
      const r = sp ? sp.getBoundingClientRect() : null;
      const view = sp && r ? { top: r.top, height: sp.clientHeight, left: r.left, width: sp.clientWidth } : { top: 0, height: window.innerHeight, left: 0, width: window.innerWidth };
      applyRef.current({ rect: el.getBoundingClientRect(), view });
    };
    const kick = () => {
      if (!raf) raf = requestAnimationFrame(run);
    };
    document.addEventListener("scroll", kick, { capture: true, passive: true });
    window.addEventListener("resize", kick);
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(kick) : null;
    if (ref.current && ro) ro.observe(ref.current);
    run();
    return () => {
      document.removeEventListener("scroll", kick, { capture: true });
      window.removeEventListener("resize", kick);
      ro?.disconnect();
      if (raf) cancelAnimationFrame(raf);
    };
  }, [ref]);
}

/** 0..1, clamped. */
const aClamp = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);

/** The user's reduced-motion setting, live. Server and first render say "no". */
function useAReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = () => setReduced(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

/** Links built from props: https, http, mailto, same-site paths and anchors only (tabs and newlines stripped, backslashes refused). */
function aSafeHref(href: string): string {
  const h = href.replace(/[\t\n\r]/g, "").trim();
  if (!h || h.includes(String.fromCharCode(92))) return "#";
  if (h.startsWith("#") || (h.startsWith("/") && !h.startsWith("//"))) return h;
  return /^(https?:|mailto:)/i.test(h) ? h : "#";
}

/** Find a same-page anchor target: [data-a-anchor="id"] inside the same site, else an element with that id. */
function aAnchor(from: Element, id: string): HTMLElement | null {
  const root = from.closest("[data-a-site]") ?? document;
  return root.querySelector<HTMLElement>('[data-a-anchor="' + id + '"]') ?? document.getElementById(id);
}

/** Glide whatever scrolls the target to a same-page anchor; anything else is left to the browser. */
function aGo(e: { currentTarget: HTMLAnchorElement; preventDefault: () => void }) {
  const href = e.currentTarget.getAttribute("href") ?? "";
  if (!href.startsWith("#") || href.length < 2) return;
  const target = aAnchor(e.currentTarget, href.slice(1));
  if (!target) return;
  e.preventDefault();
  const sp = aScrollParent(target);
  const behavior: ScrollBehavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
  if (sp) sp.scrollTo({ top: sp.scrollTop + target.getBoundingClientRect().top - sp.getBoundingClientRect().top, behavior });
  else window.scrollTo({ top: window.scrollY + target.getBoundingClientRect().top, behavior });
}

/** The kit's type: Instrument Sans (through --font-instrument) over Geist. */
const A_SANS = 'var(--font-instrument, "Instrument Sans"), "Geist", system-ui, sans-serif';

/** A link in the kit's nav, footer and plans: label and href (through aSafeHref). */
type ALink = { label: string; href: string };

const A_NAV_LINKS: ALink[] = [
  { label: "Studio", href: "#studio" },
  { label: "Rooms", href: "#hall" },
  { label: "Visit", href: "#visit" },
  { label: "Plans", href: "#plans" },
];

/** Each label bobs on its own period (seconds, primes-ish) so the row never moves in step. */
const A_PERIODS = [9.7, 11.3, 12.7, 13.9, 15.1, 16.7];

/** The kit's mark: a dot that hangs over a line, with its shadow on the line. */
function ALevMark({ size = 16 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true" className="shrink-0">
      <circle cx="8" cy="5.2" r="3" fill="currentColor" />
      <ellipse cx="8" cy="12.4" rx="3.4" ry="0.9" fill="currentColor" opacity="0.32" />
      <rect x="1.5" y="13.4" width="13" height="1.2" rx="0.6" fill="currentColor" />
    </svg>
  );
}

/** One floating link: the label, and its shadow on the shelf. Its height is written by the nav's loop. */
function AFloatLink({ link, shelf, onGo }: { link: ALink; shelf?: boolean; onGo?: () => void }) {
  const href = aSafeHref(link.href);
  return (
    <a
      href={href}
      data-a-float=""
      data-href={href}
      onClick={(e) => {
        aGo(e);
        onGo?.();
      }}
      className="group relative block rounded-[2px] px-3 pt-2.5 pb-[11px] text-[14px] leading-none font-[450] tracking-[-0.01em] text-[#eaf1f6]/72 transition-[color] duration-200 hover:text-[#eaf1f6] focus-visible:text-[#eaf1f6] focus-visible:outline-2! focus-visible:outline-offset-[-2px]! focus-visible:outline-[#d9ecf8]! aria-[current]:text-[#eaf1f6]"
    >
      {shelf && <span aria-hidden="true" className="a-shelf pointer-events-none absolute inset-x-1 bottom-[5px] h-[5px]" />}
      <span data-a-label="" className="relative z-[1] inline-block will-change-transform" style={{ transform: "translate3d(0,-5px,0)" }}>
        {link.label}
      </span>
      <span
        data-a-shadow=""
        aria-hidden="true"
        className="pointer-events-none absolute bottom-[5px] left-1/2 h-[4px] w-[64%] rounded-[50%]"
        style={{ background: "radial-gradient(closest-side, rgba(2,4,6,0.95), rgba(2,4,6,0))", transform: "translateX(-50%)" }}
      />
    </a>
  );
}

export interface LevitatingLinkNavProps {
  /** The wordmark. */
  brand?: string;
  /** Where the wordmark goes (through aSafeHref). */
  homeHref?: string;
  /** The links; "#anchor" hrefs glide to their section and, with `track`, land while it is in view. */
  links?: ALink[];
  /** The one button. null hides it. */
  cta?: ALink | null;
  /** Land the link whose section is in view ([data-a-anchor] in the same site, or an element with that id). */
  track?: boolean;
  /** How high the labels hang above the shelf, 2-10px. */
  float?: number;
  /** Let each label bob a little on its own slow period. Reduced motion always holds them still. */
  bob?: boolean;
  className?: string;
  style?: CSSProperties;
}

/**
 * LevitatingLinkNav — a dark glass bar whose link labels hang in the air
 * above a lit shelf, each over its own shadow; the one you point at lands.
 */
export function LevitatingLinkNav({
  brand = "Aerium",
  homeHref = "#top",
  links = A_NAV_LINKS,
  cta = { label: "Commission a room", href: "#commission" },
  track = true,
  float = 5,
  bob = true,
  className = "",
  style,
}: LevitatingLinkNavProps) {
  const rootRef = useRef<HTMLElement>(null);
  const barRef = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLButtonElement>(null);
  const sheetRef = useRef<HTMLDivElement>(null);
  const hoverEl = useRef<Element | null>(null);
  const active = useRef<string | null>(null);
  const kick = useRef<() => void>(() => {});
  const [open, setOpen] = useState(false);
  const sheetId = useId();
  const reduced = useAReducedMotion();
  const F = Math.min(10, Math.max(2, Number.isFinite(float) ? float : 5));
  const opts = useRef({ F, bob, reduced });
  useEffect(() => {
    opts.current = { F, bob, reduced };
    kick.current();
  }, [F, bob, reduced]);

  // The float loop: labels spring toward their height (0 = landed), shadows follow.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const st = new WeakMap<Element, { y: number; v: number; i: number }>();
    let raf = 0;
    let last = 0;
    let onScreen = true;
    const t0 = performance.now();
    const paint = (el: HTMLElement, y: number) => {
      const label = el.querySelector<HTMLElement>("[data-a-label]");
      const shadow = el.querySelector<HTMLElement>("[data-a-shadow]");
      if (label) label.style.transform = "translate3d(0," + (-y).toFixed(2) + "px,0)";
      if (shadow) {
        shadow.style.transform = "translateX(-50%) scaleX(" + (0.78 + 0.055 * y).toFixed(3) + ")";
        shadow.style.opacity = Math.max(0.18, 0.92 - 0.075 * y).toFixed(3);
        shadow.style.filter = "blur(" + (0.35 + 0.42 * y).toFixed(2) + "px)";
      }
    };
    const frame = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      const o = opts.current;
      const t = (now - t0) / 1000;
      const bobbing = o.bob && !o.reduced;
      let moving = false;
      root.querySelectorAll<HTMLElement>("[data-a-float]").forEach((el, idx) => {
        let s = st.get(el);
        if (!s) {
          s = { y: o.F, v: 0, i: idx };
          st.set(el, s);
        }
        const landed = el === hoverEl.current || (active.current !== null && el.dataset.href === active.current);
        const period = A_PERIODS[s.i % A_PERIODS.length] ?? 11;
        const goal = landed ? 0 : o.F + (bobbing ? 1.4 * Math.sin((t / period) * Math.PI * 2 + s.i * 1.9) : 0);
        if (o.reduced) {
          s.y = goal;
          s.v = 0;
        } else {
          // Critically damped spring, about 260 ms to land.
          const k = 170;
          s.v += (k * (goal - s.y) - 2 * Math.sqrt(k) * s.v) * dt;
          s.y += s.v * dt;
          if (Math.abs(goal - s.y) > 0.02 || Math.abs(s.v) > 0.02) moving = true;
        }
        paint(el, s.y);
      });
      if (onScreen && !document.hidden && (bobbing || moving)) raf = requestAnimationFrame(frame);
      else last = 0;
    };
    kick.current = () => {
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const io = typeof IntersectionObserver !== "undefined" ? new IntersectionObserver((es) => {
      onScreen = es.some((e) => e.isIntersecting);
      if (onScreen) kick.current();
    }) : null;
    io?.observe(root);
    const onVis = () => kick.current();
    document.addEventListener("visibilitychange", onVis);
    const over = (e: Event) => {
      const el = (e.target as Element | null)?.closest?.("[data-a-float]") ?? null;
      if (el !== hoverEl.current) {
        hoverEl.current = el;
        kick.current();
      }
    };
    const out = (e: Event) => {
      const to = (e as FocusEvent).relatedTarget as Element | null;
      if (!to || !root.contains(to) || !to.closest("[data-a-float]")) {
        hoverEl.current = null;
        kick.current();
      }
    };
    root.addEventListener("pointerover", over);
    root.addEventListener("focusin", over);
    root.addEventListener("pointerout", out);
    root.addEventListener("focusout", out);
    kick.current();
    return () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      io?.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      root.removeEventListener("pointerover", over);
      root.removeEventListener("focusin", over);
      root.removeEventListener("pointerout", out);
      root.removeEventListener("focusout", out);
      kick.current = () => {};
    };
  }, []);

  // Scroll: the bar tightens over the first 120px; the section in view lands its link.
  useAScrollFrame(rootRef, (f) => {
    const root = rootRef.current;
    const bar = barRef.current;
    if (!root || !bar) return;
    const sp = aScrollParent(root);
    const y = sp ? sp.scrollTop : window.scrollY;
    bar.style.setProperty("--c", aClamp(y / 120).toFixed(3));
    if (!track) return;
    // The section in view: of the targets whose top has passed 40% of the view, the lowest on the page.
    let now: string | null = null;
    let best = -Infinity;
    for (const l of links) {
      const href = aSafeHref(l.href);
      if (!href.startsWith("#") || href.length < 2) continue;
      const t = aAnchor(root, href.slice(1));
      const top = t ? t.getBoundingClientRect().top - f.view.top : Infinity;
      if (top <= f.view.height * 0.4 && top > best) {
        best = top;
        now = href;
      }
    }
    if (now !== active.current) {
      active.current = now;
      root.querySelectorAll<HTMLElement>("[data-a-float]").forEach((el) => {
        if (now !== null && el.dataset.href === now) el.setAttribute("aria-current", "location");
        else el.removeAttribute("aria-current");
      });
      kick.current();
    }
  });

  // The phone sheet: Escape and an outside tap close it; focus goes back to the button.
  useEffect(() => {
    kick.current();
    if (!open) return;
    const close = (back: boolean) => {
      setOpen(false);
      if (back) menuRef.current?.focus();
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close(true);
    };
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (!sheetRef.current?.contains(t) && !menuRef.current?.contains(t)) close(false);
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onDown);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
    };
  }, [open]);

  const ctaLink = cta ? (
    <a
      href={aSafeHref(cta.href)}
      onClick={aGo}
      className="shrink-0 rounded-[2px] bg-[#d9ecf8] px-3.5 py-2 text-[13px] leading-none font-medium whitespace-nowrap text-[#0a0e12] transition-[filter] hover:brightness-105 focus-visible:outline-2! focus-visible:outline-offset-2! focus-visible:outline-[#d9ecf8]!"
    >
      {cta.label}
    </a>
  ) : null;

  return (
    <header ref={rootRef} className={"@container relative w-full " + className} style={{ fontFamily: A_SANS, ...style }}>
      <style>{".a-shelf{background:linear-gradient(180deg,rgba(217,236,248,.62) 0,rgba(217,236,248,.2) 1px,rgba(217,236,248,.05) 5px);-webkit-mask:linear-gradient(90deg,transparent,#000 12%,#000 88%,transparent);mask:linear-gradient(90deg,transparent,#000 12%,#000 88%,transparent)}"}</style>
      <div
        ref={barRef}
        className="relative mx-auto flex w-full items-center justify-between gap-4 rounded-[2px] pl-4 backdrop-blur-[14px]"
        style={{
          maxWidth: "calc(1180px - 180px * var(--c, 0))",
          paddingTop: "calc(10px - 3px * var(--c, 0))",
          paddingBottom: "calc(10px - 3px * var(--c, 0))",
          paddingRight: "calc(10px - 2px * var(--c, 0))",
          background: "rgba(11,17,22,calc(0.5 + 0.34 * var(--c, 0)))",
          boxShadow: "inset 0 0 0 1px rgba(214,232,245,0.12), 0 18px 40px -24px rgba(0,0,0,calc(0.4 + 0.4 * var(--c, 0)))",
        }}
      >
        <a
          href={aSafeHref(homeHref)}
          onClick={aGo}
          className="flex shrink-0 items-center gap-2.5 rounded-[2px] text-[17px] leading-none font-medium tracking-[-0.035em] text-[#eaf1f6]/92 focus-visible:outline-2! focus-visible:outline-offset-4! focus-visible:outline-[#d9ecf8]!"
        >
          <ALevMark />
          {brand}
        </a>
        <nav aria-label="Main" className="relative hidden items-end @3xl:flex">
          <span aria-hidden="true" className="a-shelf pointer-events-none absolute inset-x-0 bottom-[5px] h-[5px]" />
          {links.map((l) => (
            <AFloatLink key={l.label + l.href} link={l} />
          ))}
        </nav>
        <div className="flex items-center gap-2">
          {ctaLink}
          <button
            ref={menuRef}
            type="button"
            aria-expanded={open}
            aria-controls={sheetId}
            onClick={() => setOpen((o) => !o)}
            className="rounded-[2px] px-3 py-2 text-[13px] leading-none font-medium text-[#eaf1f6] focus-visible:outline-2! focus-visible:outline-offset-2! focus-visible:outline-[#d9ecf8]! @3xl:hidden"
            style={{ boxShadow: "inset 0 0 0 1px rgba(214,232,245,0.2)" }}
          >
            {open ? "Close" : "Menu"}
          </button>
        </div>
      </div>
      {open && (
        <div
          ref={sheetRef}
          id={sheetId}
          className="absolute inset-x-0 top-full mt-2 rounded-[2px] p-3 backdrop-blur-[14px] @3xl:hidden"
          style={{ background: "rgba(11,17,22,0.9)", boxShadow: "inset 0 0 0 1px rgba(214,232,245,0.12), 0 24px 48px -20px rgba(0,0,0,0.7)" }}
        >
          <nav aria-label="Main" className="flex flex-col">
            {links.map((l) => (
              <AFloatLink key={l.label + l.href} link={l} shelf onGo={() => setOpen(false)} />
            ))}
          </nav>
        </div>
      )}
    </header>
  );
}

Free, yours to ship. Pro adds the heroes, WebGL backgrounds and complete style kits of Attention, from $19/month (launch price until 31 Oct).

Notes

About this component

A nav bar where the words do not rest on anything. Each link label hangs a few pixels above a thin lit shelf, bobbing on its own slow period so the row never moves in step, and under each one a soft shadow lies on the shelf. Point at a link, or tab to it, and it lands: it drops onto the shelf on a damped spring and its shadow tightens into a contact line. As the page scrolls, the link for the section in view stays landed, and the bar tightens and fills in over the first 120 pixels. On a phone the links move into a sheet that floats them the same way.

Curator’s note

The levitation is sold by the shadows, not the words: each label's shadow on the shelf widens, softens and fades with its height, and tightens into a contact line when it lands. One small loop writes transforms through refs and sleeps when nothing moves. Cut byte for byte from the Attention kit's one site (AttentionSite), so it reads as one system with the kit's rooms: one subject, one motivated light, one broken rule.

Related

Pairs well with

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

NewPro

Three plan slabs that lift off a lit floor as the section arrives, each over its own soft shadow; the featured one rises highest and the pointer raises any slab higher.

pricingpricing cardsplans
New

A real text input whose typed words hang above the field and cast a soft shadow on its lit shelf; focus lifts them higher and submitting lands them.

inputformtext field

Site navigation as one row of bento tiles sharing 1px hairlines, with a pointer light that shows through them and menus that open as panels of tiles.

navigationnavbarmega menu