Glass

PreviousNext

glass surface with interactive ripple and dynamic cursor effects.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/glass.tsx
"use client";
import { useRef, useEffect, useState, ReactNode, CSSProperties } from "react";
import { motion, useMotionValue, useSpring, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";

type GlassProps = {
  children?: ReactNode;
  width?: number | string;
  height?: number | string;
  borderRadius?: number;
  tintOpacity?: number;
  blur?: number;
  ripple?: boolean;
  followMouse?: boolean;
  className?: string;
  style?: CSSProperties;
  onClick?: (e: React.MouseEvent) => void;
};

type Ripple = {
  id: number;
  x: number;
  y: number;
};

export const Glass = ({
  children,
  width = 120,
  height = 120,
  borderRadius = 12,
  tintOpacity = 0.1,
  blur = 2,
  ripple = false,
  followMouse = false,
  className = "",
  style = {},
  onClick,
}: GlassProps) => {
  const glassRef = useRef<HTMLDivElement>(null);
  const left = useMotionValue(0);
  const top = useMotionValue(0);
  const [ripples, setRipples] = useState<Ripple[]>([]);

  const smoothLeft = useSpring(left, { damping: 30, stiffness: 200 });
  const smoothTop = useSpring(top, { damping: 30, stiffness: 200 });

  useEffect(() => {
    if (!followMouse) return;

    const glass = glassRef.current;
    const parent = glass?.parentElement;
    if (!glass || !parent) return;

    const w = typeof width === "number" ? width : glass.offsetWidth;
    const h = typeof height === "number" ? height : glass.offsetHeight;

    const handleTouch = (e: TouchEvent) => {
      const touch = e.touches[0];
      if (!touch) return;
      const parentRect = parent.getBoundingClientRect();
      left.set(touch.clientX - parentRect.left - w / 2);
      top.set(touch.clientY - parentRect.top - h / 2);
    };

    const handleMouse = (e: MouseEvent) => {
      const parentRect = parent.getBoundingClientRect();
      left.set(e.clientX - parentRect.left - w / 2);
      top.set(e.clientY - parentRect.top - h / 2);
    };

    window.addEventListener("mousemove", handleMouse);
    window.addEventListener("touchmove", handleTouch);
    window.addEventListener("touchstart", handleTouch);

    return () => {
      window.removeEventListener("mousemove", handleMouse);
      window.removeEventListener("touchmove", handleTouch);
      window.removeEventListener("touchstart", handleTouch);
    };
  }, [width, height, left, top, followMouse]);

  useEffect(() => {
    if (!ripple) return;

    const handleClick = (e: MouseEvent) => {
      if (!glassRef.current) return;
      const rect = glassRef.current.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const newRipple = { id: Date.now(), x, y };
      setRipples(prev => [...prev, newRipple]);
      setTimeout(() => setRipples(prev => prev.filter(r => r.id !== newRipple.id)), 1000);
    };

    window.addEventListener("click", handleClick);
    return () => window.removeEventListener("click", handleClick);
  }, [ripple]);

  const baseStyles: CSSProperties = {
    "--lg-border-radius": `${borderRadius}px`,
    "--lg-tint-opacity": tintOpacity,
    "--lg-blur": `${blur}px`,
    width,
    height,
    borderRadius: `${borderRadius}px`,
    ...(followMouse ? { left: smoothLeft, top: smoothTop } : {}),
    ...style,
  } as CSSProperties;

  const baseClasses = cn(
    followMouse ? "absolute" : "relative",
    "isolate z-[40] shadow-lg",
    "before:absolute before:inset-0 before:z-0 before:rounded-[var(--lg-border-radius)] before:bg-[rgba(255,255,255,var(--lg-tint-opacity))] before:shadow-[inset_0_0_20px_-5px_rgba(255,255,255,0.7)] before:content-['']",
    "after:absolute after:inset-0 after:isolate after:-z-[1] after:rounded-[var(--lg-border-radius)] after:[filter:url(#glass-distortion)] after:backdrop-blur-[var(--lg-blur)] after:content-['']",
    className
  );
  useEffect(() => {
    if (typeof document !== 'undefined' && !document.getElementById('glass-distortion-filter')) {
      const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svg.setAttribute('width', '0');
      svg.setAttribute('height', '0');
      svg.style.position = 'absolute';
      svg.style.overflow = 'hidden';
      svg.id = 'glass-distortion-filter';
      svg.innerHTML = `
        <defs>
          <filter id="glass-distortion" x="0%" y="0%" width="100%" height="100%">
            <feTurbulence type="fractalNoise" baseFrequency="0.008 0.008" numOctaves="2" seed="92" result="noise"/>
            <feGaussianBlur in="noise" stdDeviation="2" result="blurred"/>
            <feDisplacementMap in="SourceGraphic" in2="blurred" scale="80" xChannelSelector="R" yChannelSelector="G"/>
          </filter>
        </defs>
      `;
      document.body.appendChild(svg);
    }
  }, []);

  return (
    <>
      <motion.div
        ref={glassRef}
        className={baseClasses}
        style={baseStyles}
        onClick={onClick}
      >
        <div className="relative z-10 w-full h-full">
          {children}
        </div>

        {ripple && (
          <AnimatePresence>
            {ripples.map(ripple => (
              <motion.div
                key={ripple.id}
                className="absolute rounded-full bg-white/30 pointer-events-none"
                style={{ left: ripple.x, top: ripple.y }}
                initial={{ width: 0, height: 0, x: 0, y: 0, opacity: 1 }}
                animate={{
                  width: typeof width === "number" ? width * 2 : 200,
                  height: typeof width === "number" ? width * 2 : 200,
                  x: typeof width === "number" ? -width : -100,
                  y: typeof width === "number" ? -width : -100,
                  opacity: 0,
                }}
                exit={{ opacity: 0 }}
                transition={{ duration: 1, ease: "easeOut" }}
              />
            ))}
          </AnimatePresence>
        )}
      </motion.div>
    </>
  );
};

Installation

npx shadcn@latest add @scrollxui/glass

Usage

import { Glass } from "@/components/glass"
<Glass />