p441z/Komponen/GlassPanel
Efek · components/effects/glass-panel.tsx

GlassPanel

Frosted glass with a light rim, a pointer specular, and a springy landing.

227 barisGitHub ↗
Pasang lewat shadcn CLI

Baru pertama pakai kit ini? Baca panduan pemasangannya

Glass
Hover me.

Rim of light, a specular under the pointer, a small tilt.

Tentang

GlassPanel — a frosted-glass surface with depth. It blurs and saturates whatever sits behind it, wears a rim of light that is brightest where the light comes from, catches a soft specular that follows the pointer, tilts a few degrees toward the cursor, and assembles itself the first time it scrolls into view — it rises, comes into focus, and lands on a spring (a few pixels past its spot, then back), the way the sheets in a folder settle. Give clickable cards `press` and they squish under the finger too.

Glass needs something to frost: put it over color — the kit's `GlassBackdrop` (below), an `Aurora`, a photo, or a busy pattern. Over a flat page it reads as a plain card.

Tinted from the theme (`--color-background`, `--color-primary`) with `color-mix()`, so it works in light and dark and follows a rebrand. Pointer position is written to CSS variables — no React re-render per move. Reduced motion: no tilt, no entrance; the glass itself stays.

Cara pakai

<section className="relative">
  <GlassBackdrop />
  <GlassPanel className="p-6">…</GlassPanel>
  <GlassPanel className="p-6" delay={80} tilt={0}>…</GlassPanel>   // stagger, no tilt
  <GlassPanel contentClassName="flex items-center justify-between gap-4 px-6 py-5">…</GlassPanel>
  <GlassPanel press><a href="/docs" className="block p-6">…</a></GlassPanel>   // a card that is a link
</section>

Kode sumber

components/effects/glass-panel.tsx · 227 baris
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

/**
 * GlassPanel — a frosted-glass surface with depth. It blurs and saturates whatever sits behind it,
 * wears a rim of light that is brightest where the light comes from, catches a soft specular that
 * follows the pointer, tilts a few degrees toward the cursor, and assembles itself the first time it
 * scrolls into view — it rises, comes into focus, and lands on a spring (a few pixels past its spot,
 * then back), the way the sheets in a folder settle. Give clickable cards `press` and they squish
 * under the finger too.
 *
 * Glass needs something to frost: put it over color — the kit's `GlassBackdrop` (below), an
 * `Aurora`, a photo, or a busy pattern. Over a flat page it reads as a plain card.
 *
 * Tinted from the theme (`--color-background`, `--color-primary`) with `color-mix()`, so it works
 * in light and dark and follows a rebrand. Pointer position is written to CSS variables — no React
 * re-render per move. Reduced motion: no tilt, no entrance; the glass itself stays.
 *
 * USAGE
 *   <section className="relative">
 *     <GlassBackdrop />
 *     <GlassPanel className="p-6">…</GlassPanel>
 *     <GlassPanel className="p-6" delay={80} tilt={0}>…</GlassPanel>   // stagger, no tilt
 *     <GlassPanel contentClassName="flex items-center justify-between gap-4 px-6 py-5">…</GlassPanel>
 *     <GlassPanel press><a href="/docs" className="block p-6">…</a></GlassPanel>   // a card that is a link
 *   </section>
 */

interface GlassPanelProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Maximum tilt toward the pointer, in degrees. 0 turns it off (use it on drag surfaces). */
  tilt?: number;
  /** Assemble on first view. Off for things that are already on screen or animated by a parent. */
  appear?: boolean;
  /** Entrance delay in ms — stagger a grid with `delay={i * 70}`. */
  delay?: number;
  /** Classes for the content wrapper (the panel wraps its children) — put flex/grid layout here. */
  contentClassName?: string;
  /** Squish to 98.5 % while pressed and spring back — for panels that are (or wrap) a link or button. */
  press?: boolean;
}

const CSS = `
.p441z-glass {
  --gx: .5; --gy: 0; --tx: 0; --ty: 0; --lift: 0px;
  position: relative;
  isolation: isolate;
  background: linear-gradient(160deg, color-mix(in oklab, var(--color-background) 64%, transparent), color-mix(in oklab, var(--color-background) 40%, transparent));
  /* No saturate(): on a Retina screen it is what costs the frames (Chrome fell from 120 to ~75 fps
     with a page of saturated glass; blur alone holds 120). */
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .6), 0 1px 2px rgb(0 0 0 / .05), 0 28px 56px -32px rgb(0 0 0 / .38);
  transform: perspective(1200px) rotateX(calc(var(--ty) * var(--tilt, 3) * -1deg)) rotateY(calc(var(--tx) * var(--tilt, 3) * 1deg)) translateY(var(--lift));
  /* translate + scale land on the spring (they overshoot a few px, then settle); opacity and blur
     can't overshoot, so they ease. */
  transition:
    transform .7s cubic-bezier(.22, 1, .36, 1),
    box-shadow .5s cubic-bezier(.22, 1, .36, 1),
    opacity .9s cubic-bezier(.22, 1, .36, 1) var(--glass-delay, 0ms),
    translate 1s cubic-bezier(.34, 1.36, .5, 1) var(--glass-delay, 0ms),
    scale 1s cubic-bezier(.34, 1.36, .5, 1) var(--glass-delay, 0ms),
    filter .9s cubic-bezier(.22, 1, .36, 1) var(--glass-delay, 0ms);
}
/* Once landed, the entrance delay is dropped so a press answers at once. */
.p441z-glass[data-settled] {
  transition: transform .7s cubic-bezier(.22, 1, .36, 1), box-shadow .5s cubic-bezier(.22, 1, .36, 1), scale .45s cubic-bezier(.34, 1.36, .5, 1);
}
.p441z-glass[data-press][data-settled]:active { scale: .985; transition-duration: .7s, .5s, .1s; }
.dark .p441z-glass {
  background: linear-gradient(160deg, color-mix(in oklab, var(--color-background) 58%, transparent), color-mix(in oklab, var(--color-background) 32%, transparent));
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .09), 0 1px 2px rgb(0 0 0 / .3), 0 28px 56px -28px rgb(0 0 0 / .75);
}
.p441z-glass:hover { --lift: -3px; box-shadow: inset 0 1px 0 rgb(255 255 255 / .7), 0 1px 2px rgb(0 0 0 / .05), 0 36px 64px -30px rgb(0 0 0 / .42); }
.dark .p441z-glass:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .12), 0 1px 2px rgb(0 0 0 / .3), 0 36px 64px -26px rgb(0 0 0 / .8); }

/* Rim: a 1px gradient border, bright at the top-left, picking up the accent at the far corner. */
.p441z-glass > .p441z-glass-rim {
  position: absolute; inset: 0; z-index: 1; border-radius: inherit; padding: 1px; pointer-events: none;
  background: linear-gradient(145deg, rgb(255 255 255 / .9), rgb(255 255 255 / .2) 38%, rgb(0 0 0 / .06) 62%, color-mix(in oklab, var(--color-primary) 40%, transparent));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
}
.dark .p441z-glass > .p441z-glass-rim {
  background: linear-gradient(145deg, rgb(255 255 255 / .3), rgb(255 255 255 / .06) 40%, rgb(255 255 255 / .02) 62%, color-mix(in oklab, var(--color-primary) 55%, transparent));
}
/* Specular: soft light under the pointer. */
.p441z-glass > .p441z-glass-spec {
  position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0;
  background: radial-gradient(480px circle at calc(var(--gx) * 100%) calc(var(--gy) * 100%), rgb(255 255 255 / .55), transparent 60%);
  mix-blend-mode: soft-light;
  transition: opacity .5s cubic-bezier(.22, 1, .36, 1);
}
.dark .p441z-glass > .p441z-glass-spec {
  background: radial-gradient(480px circle at calc(var(--gx) * 100%) calc(var(--gy) * 100%), color-mix(in oklab, var(--color-primary) 22%, transparent), transparent 60%);
  mix-blend-mode: screen;
}
.p441z-glass:hover > .p441z-glass-spec { opacity: 1; }

/* Entrance: rise and come into focus. */
.p441z-glass[data-appear]:not([data-shown]) { opacity: 0; translate: 0 36px; scale: .94; filter: blur(8px); }

@media (prefers-reduced-motion: reduce) {
  .p441z-glass { transform: none !important; transition: none !important; }
  .p441z-glass:active { scale: none !important; }
  .p441z-glass[data-appear]:not([data-shown]) { opacity: 1; translate: none; scale: none; filter: none; }
}
`;

/** One copy of the stylesheet no matter how many panels (React 19 dedupes by `href`). */
function GlassStyles() {
  return (
    <style href="p441z-glass" precedence="default">
      {CSS}
    </style>
  );
}

export default function GlassPanel({ className, contentClassName, children, style, tilt = 3, appear = true, delay = 0, press = false, onPointerMove, onPointerLeave, ...rest }: GlassPanelProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [shown, setShown] = React.useState(!appear);
  const [settled, setSettled] = React.useState(!appear);

  React.useEffect(() => {
    if (!shown || settled) return;
    const t = window.setTimeout(() => setSettled(true), delay + 1000);
    return () => window.clearTimeout(t);
  }, [shown, settled, delay]);
  const raf = React.useRef(0);

  React.useEffect(() => {
    if (!appear) return;
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(
      (es) => {
        if (es.some((e) => e.isIntersecting)) {
          setShown(true);
          io.disconnect();
        }
      },
      { threshold: 0.15 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [appear]);

  React.useEffect(() => () => cancelAnimationFrame(raf.current), []);

  const write = (gx: number, gy: number) => {
    cancelAnimationFrame(raf.current);
    raf.current = requestAnimationFrame(() => {
      const el = ref.current;
      if (!el) return;
      el.style.setProperty("--gx", gx.toFixed(3));
      el.style.setProperty("--gy", gy.toFixed(3));
      el.style.setProperty("--tx", (gx * 2 - 1).toFixed(3));
      el.style.setProperty("--ty", (gy * 2 - 1).toFixed(3));
    });
  };

  return (
    <div
      ref={ref}
      data-appear={appear || undefined}
      data-shown={shown || undefined}
      data-settled={settled || undefined}
      data-press={press || undefined}
      className={cn("p441z-glass rounded-2xl", className)}
      style={{ "--tilt": tilt, "--glass-delay": `${delay}ms`, ...style } as React.CSSProperties}
      onPointerMove={(e) => {
        onPointerMove?.(e);
        if (e.pointerType === "touch") return;
        const r = e.currentTarget.getBoundingClientRect();
        write((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
      }}
      onPointerLeave={(e) => {
        onPointerLeave?.(e);
        write(0.5, 0.5);
      }}
      {...rest}
    >
      <GlassStyles />
      <div aria-hidden className="p441z-glass-spec" />
      <div aria-hidden className="p441z-glass-rim" />
      <div className={cn("relative z-[2]", contentClassName)}>{children}</div>
    </div>
  );
}

/**
 * GlassBackdrop — soft pools of the theme's primary color for glass to frost. Static (no animation,
 * no filter): radial gradients only, so it costs nothing while scrolling. Put it first inside a
 * `relative` section; it sits behind everything. Its edges fade out on all four sides, so it never
 * shows a hard line — make it wider than the section (full-bleed) for a softer spread.
 *
 * USAGE
 *   <section className="relative"><GlassBackdrop variant="b" />…</section>
 */
const FADE = "linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent), linear-gradient(to right, transparent, #000 10%, #000 90%, transparent)";

export function GlassBackdrop({ className, variant = "a", strength = 1 }: { className?: string; variant?: "a" | "b" | "c"; strength?: number }) {
  const spots = {
    a: [["18% 22%", 34], ["82% 38%", 26], ["46% 88%", 20]],
    b: [["78% 18%", 32], ["14% 60%", 24], ["60% 92%", 18]],
    c: [["50% 12%", 30], ["10% 84%", 22], ["92% 74%", 24]],
  }[variant] as [string, number][];
  const pool = (at: string, pct: number) => `radial-gradient(38% 46% at ${at}, color-mix(in oklab, var(--color-primary) ${Math.round(pct * strength)}%, transparent), transparent 70%)`;
  return (
    <div
      aria-hidden
      className={cn("pointer-events-none absolute inset-0 -z-10", className)}
      style={{
        background: [...spots.map(([at, pct]) => pool(at, pct)), "radial-gradient(30% 36% at 60% 40%, color-mix(in oklab, var(--color-foreground) 5%, transparent), transparent 70%)"].join(", "),
        maskImage: FADE,
        WebkitMaskImage: FADE,
        maskComposite: "intersect",
        WebkitMaskComposite: "source-in",
      }}
    />
  );
}