NEMAWASHI LAB

Glass

CSS

A frosted-glass tooltip that follows the cursor with a 22 px offset, appearing when you hover each phrase of a four-part sentence. The pill uses backdrop-filter blur at 40 px plus saturate 1.9 and a translucent white gradient fill with an inset highlight border, giving it the liquid-glass look of visionOS. Non-hovered phrases dim to 0.35 opacity and pick up a 1.5 px blur, creating a shallow depth-of-field focus effect around the active word. Entrance and exit are staggered -- opacity at 460 ms, blur at 540 ms, scale at 500 ms -- so the pill materialises rather than snaps.

GIRAGIRAis a labfor things thatcatch light.

Hover each phrase to see the liquid-glass tooltip

Source

GlassText.tsx204 lines
"use client";

import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";

interface HintConfig {
  text: string;
  hint: string;
}

const HINTS: HintConfig[] = [
  { text: "GIRAGIRA", hint: "Glare — from Japanese ギラギラ" },
  { text: "is a lab", hint: "Experiments in type, colour & motion" },
  { text: "for things that", hint: "No rules, just play" },
  { text: "catch light.", hint: "✦" },
];

const OFFSET = 22;

export default function GlassText() {
  const [activeHint, setActiveHint] = useState<string | null>(null);
  const [activeIdx, setActiveIdx] = useState<number | null>(null);
  const hintRef = useRef<HTMLDivElement>(null);
  const cursorRef = useRef({ x: 0, y: 0 });
  const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    const onMove = (e: MouseEvent) => {
      cursorRef.current = { x: e.clientX, y: e.clientY };
      const el = hintRef.current;
      if (!el || !activeHint) return;
      const w = el.offsetWidth || 200;
      const h = el.offsetHeight || 48;
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      let left = e.clientX + OFFSET;
      let top = e.clientY + OFFSET;
      if (left + w > vw - 12) left = e.clientX - w - OFFSET;
      if (top + h > vh - 12) top = e.clientY - h - OFFSET;
      if (left < 12) left = 12;
      if (top < 12) top = 12;
      el.style.left = `${left}px`;
      el.style.top = `${top}px`;
    };
    document.addEventListener("mousemove", onMove);
    return () => document.removeEventListener("mousemove", onMove);
  }, [activeHint]);

  const show = (hint: string, idx: number) => {
    if (hideTimerRef.current) {
      clearTimeout(hideTimerRef.current);
      hideTimerRef.current = null;
    }
    setActiveHint(hint);
    setActiveIdx(idx);
    requestAnimationFrame(() => {
      const el = hintRef.current;
      if (!el) return;
      const c = cursorRef.current;
      const w = el.offsetWidth || 200;
      const h = el.offsetHeight || 48;
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      let left = c.x + OFFSET;
      let top = c.y + OFFSET;
      if (left + w > vw - 12) left = c.x - w - OFFSET;
      if (top + h > vh - 12) top = c.y - h - OFFSET;
      el.style.left = `${left}px`;
      el.style.top = `${top}px`;
    });
  };

  const hide = () => {
    hideTimerRef.current = setTimeout(() => {
      setActiveHint(null);
      setActiveIdx(null);
      hideTimerRef.current = null;
    }, 120);
  };

  return (
    <div className="fixed inset-0 flex items-center justify-center select-none overflow-hidden">
      {/* Gradient background for the glass effect to read against */}
      <div
        className="absolute inset-0"
        style={{
          background: "linear-gradient(135deg, #dcd9fb 0%, #f99a9e 25%, #fbd9da 45%, #ecdca7 65%, #afcae4 85%, #daebfb 100%)",
        }}
      />

      {/* Soft noise texture overlay */}
      <div
        className="absolute inset-0 opacity-20"
        style={{
          backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.4'/%3E%3C/svg%3E")`,
          backgroundSize: "200px 200px",
        }}
      />

      {/* Glass hint pill */}
      <div
        ref={hintRef}
        className="fixed pointer-events-none"
        style={{
          zIndex: 200,
          background:
            "linear-gradient(135deg, rgba(255,255,255,0.30) 0%, rgba(255,255,255,0.08) 100%)",
          color: "#0a0a0a",
          borderRadius: "999px",
          padding: "14px 24px",
          fontFamily: "system-ui, -apple-system, sans-serif",
          fontSize: "18px",
          fontWeight: 500,
          letterSpacing: "-0.005em",
          whiteSpace: "nowrap",
          backdropFilter: "blur(40px) saturate(1.9) brightness(1.04)",
          WebkitBackdropFilter: "blur(40px) saturate(1.9) brightness(1.04)",
          border: "1px solid rgba(255,255,255,0.45)",
          boxShadow: `
            inset 0 1px 0 rgba(255,255,255,0.7),
            inset 0 -1px 0 rgba(255,255,255,0.08),
            0 1px 2px rgba(15,17,22,0.04),
            0 14px 36px rgba(15,17,22,0.10),
            0 32px 72px rgba(15,17,22,0.10)
          `,
          opacity: activeHint ? 1 : 0,
          filter: activeHint ? "blur(0)" : "blur(10px)",
          transform: activeHint ? "scale(1)" : "scale(0.96)",
          transformOrigin: "top left",
          transition: `
            opacity 460ms cubic-bezier(0.22, 1, 0.36, 1),
            filter 540ms cubic-bezier(0.22, 1, 0.36, 1),
            transform 500ms cubic-bezier(0.22, 1, 0.36, 1)
          `,
        }}
      >
        {activeHint}
      </div>

      {/* Text content */}
      <div className="relative z-10 text-center px-8">
        <p
          className="flex flex-wrap justify-center gap-x-[0.35em] leading-[1.2]"
          style={{
            fontFamily: "var(--font-flux), Georgia, serif",
            fontSize: "clamp(28px, 5vw, 64px)",
            fontVariationSettings: "'wght' 300, 'SRIF' 0",
            color: "rgba(255,255,255,0.95)",
          }}
        >
          {HINTS.map((item, i) => {
            const isActive = activeIdx === i;
            const hasSomeActive = activeIdx !== null;
            return (
              <span
                key={i}
                className="inline-block cursor-pointer italic"
                style={{
                  textDecorationLine: "underline",
                  textDecorationThickness: "1px",
                  textUnderlineOffset: "0.16em",
                  textDecorationColor: isActive
                    ? "rgba(255,255,255,0.7)"
                    : "rgba(255,255,255,0.4)",
                  opacity: hasSomeActive ? (isActive ? 1 : 0.35) : 1,
                  transform: isActive ? "translateY(-3px) scale(1.03)" : "none",
                  filter: hasSomeActive && !isActive ? "blur(1.5px)" : "none",
                  transition:
                    "opacity 340ms cubic-bezier(0.22, 1, 0.36, 1), transform 400ms cubic-bezier(0.22, 1, 0.36, 1), filter 340ms cubic-bezier(0.22, 1, 0.36, 1), text-decoration-color 280ms ease",
                }}
                onMouseEnter={() => show(item.hint, i)}
                onMouseLeave={hide}
              >
                {item.text}
              </span>
            );
          })}
        </p>

        <p
          className="mt-10 text-white/40"
          style={{
            fontSize: "13px",
            fontVariationSettings: "'wght' 300, 'SRIF' 100",
            letterSpacing: "0.04em",
          }}
        >
          Hover each phrase to see the liquid-glass tooltip
        </p>
      </div>

      <div className="fixed bottom-6 left-6 z-10">
        <ScrambleLink
          from="GLASS"
          to="← LAB"
          href="/lab"
          className="text-[11px] tracking-[0.1em] text-white/30 uppercase hover:text-white/60 transition-colors"
          style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe