Hero liquid glass
Piezas de vidrio que refractan un fondo líquido en movimiento y se desplazan con el puntero a distintas profundidades. Refracción real en Chromium; vidrio esmerilado en el resto.
Diseño que se ve a través
Superficies de vidrio que refractan la luz de lo que hay detrás. Un lenguaje visual para productos que quieren sentirse vivos.
Prueba el material con el puntero, al pulsar y con el teclado.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { createElement, useCallback, useEffect, useId, useState, useSyncExternalStore, type CSSProperties, type PointerEvent, type ReactNode } from "react";
/** Hero liquid glass: piezas de cristal que refractan un fondo líquido en movimiento y se desplazan con el puntero a distintas profundidades. Refracción real en Chromium; cristal con desenfoque en el resto. React + TypeScript, sin dependencias. Personaliza con --gchlq-*. */
export type HeroLiquidGlassProps = {
title?: string;
description?: string;
primary?: string;
secondary?: string;
href?: string;
variant?: "regular" | "clear";
scheme?: "dark" | "light";
className?: string;
style?: CSSProperties;
};
const CSS = `
.gchlq-root { box-sizing: border-box; position: relative; isolation: isolate; container-type: inline-size; width: 100%; min-height: 640px; overflow: hidden; color: #fff; background: var(--gchlq-bg, #130b3d); font: 500 16px/1.5 -apple-system, "SF Pro Display", ui-sans-serif, system-ui, sans-serif; }
.gchlq-root *, .gchlq-root *::before, .gchlq-root *::after { box-sizing: border-box; }
.gchlq-root button, .gchlq-root a { -webkit-tap-highlight-color: transparent; }
.gchlq-root :focus-visible { outline: 2px solid #fff; outline-offset: 4px; }
.gchlq-root[data-s="light"] { color: #17202b; text-shadow: none; }
.gchlq-root[data-s="light"] { background: var(--gchlq-bg,#e4eaf0); } .gchlq-root[data-s="light"] :is(.gchlq-nav a,.gchlq-btn) { color: #17202b; text-shadow: none; }
@property --gchlq-a { syntax: "<angle>"; inherits: true; initial-value: 135deg; }
@property --gchlq-p { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --gchlq-sx { syntax: "<percentage>"; inherits: true; initial-value: 24%; }
@property --gchlq-sy { syntax: "<percentage>"; inherits: true; initial-value: 8%; }
@property --gchlq-hi { syntax: "<number>"; inherits: true; initial-value: .78; }
@property --gchlq-tc { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gchlq-te { syntax: "<color>"; inherits: true; initial-value: transparent; }
.gchlq-g, .gchlq-gf {
--gchlq-a: 135deg; --gchlq-p: 0deg; --gchlq-sx: 24%; --gchlq-sy: 8%;
--gchlq-bl: var(--gchlq-blur, 2.6px); --gchlq-tc: rgba(255,255,255,.025); --gchlq-te: rgba(255,255,255,.12);
--gchlq-sh: rgba(12,16,24,.16); --gchlq-hi: .78; --gchlq-ink: 12,16,24; --gchlq-ins: 3px;
--gchlq-contact: .14; --gchlq-ambient: .2;
position: relative; isolation: isolate; border: 0; color: var(--gchlq-fg, #fff);
background: radial-gradient(115% 120% at 50% 40%, var(--gchlq-tc) 42%, var(--gchlq-te) 100%), linear-gradient(var(--gchlq-sh),var(--gchlq-sh));
-webkit-backdrop-filter: blur(var(--gchlq-bl)) saturate(1.08) brightness(1.02);
backdrop-filter: blur(var(--gchlq-bl)) saturate(1.08) brightness(1.02);
box-shadow: inset .5px 1px 1px rgba(255,255,255,.42), inset -.5px -1px 1.5px rgba(255,255,255,.16), inset 2px 3px 7px -5px rgba(255,255,255,.45), inset -2px -4px 8px -5px rgba(var(--gchlq-ink),.26), 0 1px 2px rgba(var(--gchlq-ink),var(--gchlq-contact)), 0 12px 28px -10px rgba(var(--gchlq-ink),var(--gchlq-ambient));
transition: --gchlq-a 280ms cubic-bezier(.16,1,.3,1), --gchlq-p 320ms cubic-bezier(.22,1.08,.36,1), --gchlq-sx 240ms cubic-bezier(.16,1,.3,1), --gchlq-sy 240ms cubic-bezier(.16,1,.3,1), --gchlq-hi 180ms ease-out, --gchlq-tc 180ms ease-out, --gchlq-te 180ms ease-out, box-shadow 240ms ease-out, transform 360ms cubic-bezier(.22,1.08,.36,1);
}
.gchlq-g[data-v="clear"], .gchlq-gf[data-v="clear"] { --gchlq-bl: var(--gchlq-clear-blur,.45px); --gchlq-tc: rgba(255,255,255,.008); --gchlq-te: rgba(255,255,255,.065); --gchlq-sh: rgba(12,16,24,.07); }
.gchlq-root[data-s="light"] :is(.gchlq-g,.gchlq-gf), .gchlq-g[data-s="light"], .gchlq-gf[data-s="light"] { --gchlq-tc: rgba(255,255,255,.06); --gchlq-te: rgba(255,255,255,.18); --gchlq-sh: rgba(255,255,255,.025); --gchlq-ink: 24,30,38; --gchlq-fg: #17202b; --gchlq-contact: .09; --gchlq-ambient: .13; }
.gchlq-root[data-s="light"] :is(.gchlq-g,.gchlq-gf)[data-v="clear"], .gchlq-g[data-s="light"][data-v="clear"], .gchlq-gf[data-s="light"][data-v="clear"] { --gchlq-tc: rgba(255,255,255,.015); --gchlq-te: rgba(255,255,255,.085); }
/* Nested controls share their parent's optical plane; never blur glass twice. */
.gchlq-gf { -webkit-backdrop-filter: none; backdrop-filter: none; --gchlq-tc: rgba(255,255,255,.04); --gchlq-te: rgba(255,255,255,.12); --gchlq-sh: transparent; }
.gchlq-g[data-r="1"] { backdrop-filter: blur(var(--gchlq-bl)) var(--gchlq-f) saturate(1.08) brightness(1.02); }
.gchlq-g[data-t="solid"], .gchlq-gf[data-t="solid"] { --gchlq-tc: rgba(255,255,255,.19); --gchlq-te: rgba(255,255,255,.32); --gchlq-sh: rgba(12,16,24,.2); font-weight: 650; }
.gchlq-g[data-t="solid"][data-s="light"], .gchlq-gf[data-t="solid"][data-s="light"] { --gchlq-sh: rgba(255,255,255,.08); }
.gchlq-g::before, .gchlq-gf::before { content: ""; position: absolute; inset: 0; z-index: 3; padding: .8px; border-radius: inherit; pointer-events: none; opacity: var(--gchlq-hi);
background: conic-gradient(from calc(var(--gchlq-a) + var(--gchlq-p)), rgba(255,255,255,.04), rgba(255,255,255,.06) 12%, rgba(255,255,255,.88) 21%, rgba(255,255,255,.16) 28%, rgba(255,255,255,.025) 42%, rgba(255,255,255,.04) 60%, rgba(255,255,255,.62) 72%, rgba(255,255,255,.13) 79%, rgba(255,255,255,.04));
-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;
}
.gchlq-g::after, .gchlq-gf::after { content: ""; position: absolute; inset: var(--gchlq-ins); z-index: -1; border-radius: max(0px,calc(var(--gchlq-r,24px) - var(--gchlq-ins))); pointer-events: none; opacity: .55; transition: opacity 180ms ease-out;
background: radial-gradient(38% 24% at var(--gchlq-sx) var(--gchlq-sy), rgba(255,255,255,.22), transparent 75%), radial-gradient(30% 18% at 84% 98%, rgba(255,255,255,.085), transparent 75%);
box-shadow: inset .5px 1px 1px rgba(255,255,255,.15), inset -.5px -1px 2px rgba(var(--gchlq-ink),.12);
}
.gchlq-root[data-s="light"] :is(.gchlq-g,.gchlq-gf)::before, .gchlq-g[data-s="light"]::before, .gchlq-gf[data-s="light"]::before { background: conic-gradient(from calc(var(--gchlq-a) + var(--gchlq-p)), rgba(var(--gchlq-ink),.08), rgba(255,255,255,.3) 12%, #fff 21%, rgba(255,255,255,.25) 28%, rgba(var(--gchlq-ink),.13) 45%, rgba(var(--gchlq-ink),.03) 60%, rgba(255,255,255,.95) 72%, rgba(255,255,255,.3) 79%, rgba(var(--gchlq-ink),.08)); }
.gchlq-g[data-e="2"] { --gchlq-contact: .17; --gchlq-ambient: .25; box-shadow: inset .5px 1px 1px rgba(255,255,255,.5), inset -.5px -1px 2px rgba(255,255,255,.2), inset 2px 3px 8px -5px rgba(255,255,255,.45), inset -2px -5px 10px -6px rgba(var(--gchlq-ink),.24), 0 2px 4px rgba(var(--gchlq-ink),var(--gchlq-contact)), 0 24px 52px -18px rgba(var(--gchlq-ink),var(--gchlq-ambient)); }
.gchlq-g[data-i], .gchlq-gf[data-i] { cursor: pointer; touch-action: manipulation; }
.gchlq-g:focus-visible, .gchlq-gf:focus-visible { outline: 2px solid var(--gchlq-fg,#fff); outline-offset: 4px; }
@media (hover: hover) and (pointer: fine) {
.gchlq-g[data-i]:hover:not(:disabled), .gchlq-gf[data-i]:hover:not(:disabled) { --gchlq-hi: .98; }
.gchlq-g[data-i]:hover:not(:disabled)::after, .gchlq-gf[data-i]:hover:not(:disabled)::after { opacity: .85; }
.gchlq-g[data-t="solid"][data-i]:hover:not(:disabled), .gchlq-gf[data-t="solid"][data-i]:hover:not(:disabled) { --gchlq-tc: rgba(255,255,255,.25); }
}
.gchlq-g[data-i]:active:not(:disabled), .gchlq-gf[data-i]:active:not(:disabled) { --gchlq-p: 14deg; --gchlq-hi: 1; transform: scale(.985,.97); transition-duration: 80ms; }
.gchlq-g[data-i]:active:not(:disabled)::after, .gchlq-gf[data-i]:active:not(:disabled)::after { opacity: .95; }
.gchlq-g:disabled, .gchlq-gf:disabled { cursor: not-allowed; }
.gchlq-svg { position: absolute; width: 0; height: 0; overflow: hidden; pointer-events: none; }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.gchlq-g { background: rgba(30,36,46,.9); }
.gchlq-root[data-s="light"] .gchlq-g, .gchlq-g[data-s="light"] { background: rgba(245,247,250,.94); }
}
@media (prefers-reduced-transparency: reduce) { .gchlq-g, .gchlq-gf { background: #26303d; color: #fff; -webkit-backdrop-filter: none; backdrop-filter: none; } .gchlq-root[data-s="light"] :is(.gchlq-g,.gchlq-gf), .gchlq-g[data-s="light"], .gchlq-gf[data-s="light"] { background: #f2f5f8; color: #17202b; } }
@media (prefers-reduced-motion: reduce) {
.gchlq-root *, .gchlq-root *::before, .gchlq-root *::after { animation: none !important; transition-duration: .01ms !important; }
.gchlq-g[data-i]:active:not(:disabled), .gchlq-gf[data-i]:active:not(:disabled) { transform: none; }
}
@media (forced-colors: active) { .gchlq-g, .gchlq-gf { border: 1px solid CanvasText; background: Canvas; color: CanvasText; -webkit-backdrop-filter: none; backdrop-filter: none; } .gchlq-g::before, .gchlq-g::after, .gchlq-gf::before, .gchlq-gf::after { display: none; } }
.gchlq-blob { position: absolute; z-index: -2; border-radius: 50%; filter: blur(40px); will-change: transform; }
.gchlq-b1 { width: 52%; aspect-ratio: 1; left: -8%; top: -14%; background: #ff4fb8; animation: gchlq-a 14s ease-in-out infinite alternate; }
.gchlq-b2 { width: 46%; aspect-ratio: 1; right: -6%; top: 6%; background: #2ec8ff; animation: gchlq-b 17s ease-in-out infinite alternate; }
.gchlq-b3 { width: 40%; aspect-ratio: 1; left: 30%; bottom: -24%; background: #ffc857; animation: gchlq-c 19s ease-in-out infinite alternate; }
.gchlq-b4 { width: 26%; aspect-ratio: 1; left: 8%; bottom: 4%; background: #7b5cff; animation: gchlq-b 12s ease-in-out infinite alternate-reverse; }
.gchlq-lines { position: absolute; inset: 0; z-index: -1; pointer-events: none; background: repeating-linear-gradient(90deg, transparent 0 63px, rgba(255, 255, 255, .13) 63px 64px), repeating-linear-gradient(0deg, transparent 0 63px, rgba(255, 255, 255, .13) 63px 64px); -webkit-mask-image: radial-gradient(120% 90% at 50% 50%, #000 30%, transparent 85%); mask-image: radial-gradient(120% 90% at 50% 50%, #000 30%, transparent 85%); }
.gchlq-layer { position: absolute; inset: 0; transform: translate(calc(var(--px, 0) * var(--d) * 1px), calc(var(--py, 0) * var(--d) * 1px)); transition: transform .6s cubic-bezier(.16, 1, .3, 1); pointer-events: none; }
.gchlq-orb { position: absolute; width: 168px; height: 168px; left: 9%; top: 52%; border-radius: 50%; }
.gchlq-hl { position: absolute; top: 13%; left: 17%; width: 36%; height: 17%; border-radius: 50%; background: radial-gradient(closest-side, rgba(255, 255, 255, .55), transparent); transform: rotate(-32deg); }
.gchlq-pill { position: absolute; right: 8%; top: 22%; width: 214px; height: 64px; border-radius: 999px; display: flex; align-items: center; gap: 12px; padding: 0 22px; font-size: 14px; font-weight: 600; text-shadow: 0 1px 2px rgba(10, 4, 50, .3); }
.gchlq-pill i { width: 10px; height: 10px; border-radius: 50%; background: #62e8a6; box-shadow: inset 0 1px 1px rgba(255, 255, 255, .6), inset 0 -1px 2px rgba(0, 70, 30, .35), 0 1px 3px rgba(8, 6, 40, .35); }
.gchlq-stat { position: absolute; right: 14%; bottom: 14%; width: 200px; padding: 18px 22px; border-radius: 30px; text-shadow: 0 1px 2px rgba(10, 4, 50, .3); }
.gchlq-stat b { display: block; font-size: 34px; letter-spacing: -.03em; line-height: 1; }
.gchlq-stat span { font-size: 13px; opacity: .9; }
.gchlq-copy { position: relative; z-index: 3; display: grid; place-items: center; align-content: center; gap: 22px; min-height: 640px; padding: 96px clamp(24px, 6cqi, 80px) 72px; text-align: center; }
.gchlq-nav { position: absolute; z-index: 4; top: 20px; left: 50%; transform: translateX(-50%); display: flex; gap: 4px; padding: 6px; border-radius: 999px; }
.gchlq-nav a { display: inline-flex; align-items: center; min-height: 44px; padding: 9px 18px; border-radius: 999px; color: #fff; font-size: 14px; font-weight: 600; text-decoration: none; text-shadow: 0 1px 2px rgba(10, 4, 50, .3); transition: background .25s, box-shadow .25s; }
.gchlq-nav a:hover { background: rgba(255, 255, 255, .14); }
.gchlq-nav a:first-child { background: rgba(255, 255, 255, .2); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22), inset 1px 2px 2px rgba(255, 255, 255, .3); }
.gchlq-root .gchlq-title { color: inherit; margin: 0; max-width: 14ch; font: 700 clamp(36px, 9cqi, 96px)/.98 -apple-system, "SF Pro Display", ui-sans-serif, system-ui, sans-serif; letter-spacing: -.04em; text-wrap: balance; text-shadow: 0 2px 30px rgba(20, 8, 80, .35); }
.gchlq-root .gchlq-desc { color: inherit; margin: 0; max-width: 46ch; font-size: clamp(16px, 2cqi, 20px); opacity: .94; text-shadow: 0 1px 12px rgba(20, 8, 80, .45); text-wrap: pretty; }
.gchlq-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; }
.gchlq-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 54px; padding: 0 30px; border-radius: 999px; font-weight: 600; text-decoration: none; text-shadow: 0 1px 2px rgba(10, 4, 50, .3); }
@container (max-width: 760px) { .gchlq-pill, .gchlq-stat { display: none; } .gchlq-orb { width: 104px; height: 104px; left: 6%; top: 74%; } .gchlq-nav { display: none; } }
@media (prefers-reduced-motion: reduce) { .gchlq-root *, .gchlq-root *::before, .gchlq-root *::after { animation: none !important; } .gchlq-layer { transition-duration: .01ms; } }
@keyframes gchlq-a { to { transform: translate(24%, 18%) scale(1.15); } }
@keyframes gchlq-b { to { transform: translate(-22%, 26%) scale(.9); } }
@keyframes gchlq-c { to { transform: translate(-30%, -16%) scale(1.2); } }
@keyframes gchlq-float { 50% { transform: translateY(-16px); } }`;
/* Motor de vidrio líquido: mapa de desplazamiento por borde (refracción) + dispersión leve, calculado a la medida de cada pieza. */
const noop = () => () => {};
const canRefract = () => typeof window !== "undefined" && typeof window.CSS !== "undefined" && window.CSS.supports("backdrop-filter", "url(#a)") && /Chrome\//.test(navigator.userAgent);
const bend = (t: number) => {
const u = 1 - t;
const th = Math.atan(Math.pow(Math.max(1 - u * u * u * u, 1e-6), -0.75) * u * u * u * 0.9);
return Math.sin(th - Math.asin(Math.sin(th) / 1.5));
};
const PEAK = bend(0.02);
/** Mapa R/G de desplazamiento: 0 en el centro, máximo en el borde (perfil de cristal convexo y ley de Snell, n = 1.5). */
const mapCache = new Map<string, string>();
function glassMap(w: number, h: number, r: number, bezel: number): string {
const key = [w, h, r, bezel].join(":");
const cached = mapCache.get(key);
if (cached) return cached;
const k = Math.min(1, Math.sqrt(90000 / (w * h)));
const cw = Math.max(2, Math.round(w * k));
const ch = Math.max(2, Math.round(h * k));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
const img = ctx.createImageData(cw, ch);
const hx = w / 2;
const hy = h / 2;
for (let j = 0; j < ch; j++) {
for (let i = 0; i < cw; i++) {
const px = (i + 0.5) * w / cw - hx;
const py = (j + 0.5) * h / ch - hy;
const qx = Math.abs(px) - (hx - r);
const qy = Math.abs(py) - (hy - r);
let nx = 0;
let ny = 0;
let d: number;
if (qx > 0 && qy > 0) {
const l = Math.hypot(qx, qy);
d = r - l;
nx = qx / l;
ny = qy / l;
} else if (qx > qy) {
d = hx - Math.abs(px);
nx = 1;
} else {
d = hy - Math.abs(py);
ny = 1;
}
const t = d / bezel;
const m = t >= 1 ? 0 : Math.min(1, bend(Math.max(t, 0.02)) / PEAK);
const o = (j * cw + i) * 4;
img.data[o] = 128 - (Math.sign(px) || 1) * nx * m * 127;
img.data[o + 1] = 128 - (Math.sign(py) || 1) * ny * m * 127;
img.data[o + 2] = 128;
img.data[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
const map = canvas.toDataURL();
if (mapCache.size >= 24) mapCache.delete(mapCache.keys().next().value!);
mapCache.set(key, map);
return map;
}
type SurfaceProps = {
as?: string;
/** Radio de las esquinas en px; por defecto el que resuelve el CSS. */
radius?: number;
/** Ancho de la franja que refracta, en px. */
bezel?: number;
/** Desplazamiento máximo del fondo en el borde, en px. */
depth?: number;
variant?: "regular" | "clear";
/** Solid: mayor contraste para la acción principal, conservando transparencia. */
tone?: "solid";
scheme?: "dark" | "light";
elevated?: boolean;
flat?: boolean;
interactive?: boolean;
elRef?: (node: HTMLElement | null) => void;
className?: string;
style?: CSSProperties;
children?: ReactNode;
onPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onPointerLeave?: (event: PointerEvent<HTMLElement>) => void;
[prop: string]: unknown;
};
/** Superficie de vidrio: refracta el fondo en Chromium (SVG + backdrop-filter) y cae a vidrio con desenfoque en el resto; el borde óptico, el volumen y las sombras no dependen del filtro. */
function Surface({ as = "div", radius, bezel, depth, variant = "regular", tone, scheme = "dark", elevated, flat, interactive, elRef, className = "", style, children, onPointerMove, onPointerLeave, ...rest }: SurfaceProps) {
const id = `gchlq-f-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
const refract = useSyncExternalStore(noop, canRefract, () => false) && !flat;
const [node, setNode] = useState<HTMLElement | null>(null);
const [lens, setLens] = useState<{ w: number; h: number; r: number; depth: number; map: string } | null>(null);
const bind = useCallback(
(el: HTMLElement | null) => {
setNode(el);
elRef?.(el);
},
[elRef],
);
useEffect(() => {
if (!node || !refract) return;
let lw = 0;
let lh = 0;
let frame = 0;
const measure = () => {
const w = node.offsetWidth;
const h = node.offsetHeight;
if (!w || !h || (w === lw && h === lh)) return;
lw = w;
lh = h;
const corner = getComputedStyle(node).borderTopLeftRadius;
const resolved = corner.endsWith("%") ? parseFloat(corner) / 100 * Math.min(w, h) : parseFloat(corner) || 0;
const r = Math.min(radius ?? resolved, w / 2, h / 2);
const bz = Math.max(6, Math.min(bezel ?? Math.min(w, h) * 0.24, Math.min(w, h) / 2, 48));
const map = glassMap(w, h, r, bz);
setLens(map ? { w, h, r, depth: (depth ?? Math.round(bz * .85)) * (variant === "clear" ? 1 : .85), map } : null);
};
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
});
observer.observe(node);
return () => { observer.disconnect(); cancelAnimationFrame(frame); };
}, [node, refract, radius, bezel, depth, variant]);
const live = refract && lens;
const move = (event: PointerEvent<HTMLElement>) => {
if (event.pointerType !== "touch" && !event.currentTarget.matches(":disabled") && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const box = event.currentTarget.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (event.clientX - box.left) / box.width));
const y = Math.max(0, Math.min(1, (event.clientY - box.top) / box.height));
event.currentTarget.style.setProperty("--gchlq-a", `${135 + (x - 0.5) * 40 - (y - 0.5) * 18}deg`);
event.currentTarget.style.setProperty("--gchlq-sx", `${18 + x * 64}%`);
event.currentTarget.style.setProperty("--gchlq-sy", `${3 + y * 12}%`);
}
onPointerMove?.(event);
};
const leave = (event: PointerEvent<HTMLElement>) => {
for (const name of ["a", "sx", "sy"]) event.currentTarget.style.removeProperty(`--gchlq-${name}`);
onPointerLeave?.(event);
};
return createElement(
as,
{
...rest,
ref: bind,
className: `gchlq-${flat ? "gf" : "g"} ${className}`,
"data-v": variant,
"data-s": scheme,
"data-t": tone,
"data-r": live ? "1" : undefined,
"data-e": elevated ? "2" : undefined,
"data-i": interactive ? "1" : undefined,
style: { ...style, "--gchlq-r": live ? `${lens.r}px` : radius !== undefined ? `${radius}px` : undefined, "--gchlq-f": live ? `url(#${id})` : undefined } as CSSProperties,
onPointerMove: move,
onPointerLeave: leave,
},
live ? (
<svg className="gchlq-svg" aria-hidden="true" focusable="false">
<filter id={id} filterUnits="userSpaceOnUse" x={-lens.depth} y={-lens.depth} width={lens.w + lens.depth * 2} height={lens.h + lens.depth * 2} colorInterpolationFilters="sRGB">
<feImage href={lens.map} x={0} y={0} width={lens.w} height={lens.h} preserveAspectRatio="none" result="m" />
<feDisplacementMap in="SourceGraphic" in2="m" scale={lens.depth * 1.012} xChannelSelector="R" yChannelSelector="G" result="dr" />
<feColorMatrix in="dr" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1" result="cr" />
<feDisplacementMap in="SourceGraphic" in2="m" scale={lens.depth} xChannelSelector="R" yChannelSelector="G" result="dg" />
<feColorMatrix in="dg" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1" result="cg" />
<feDisplacementMap in="SourceGraphic" in2="m" scale={lens.depth * .988} xChannelSelector="R" yChannelSelector="G" result="db" />
<feColorMatrix in="db" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1" result="cb" />
<feBlend in="cr" in2="cg" mode="screen" result="rg" />
<feBlend in="rg" in2="cb" mode="screen" />
</filter>
</svg>
) : null,
children,
);
}
export function HeroLiquidGlass({ title = "Diseño que se ve a través", description = "Superficies de vidrio que refractan la luz de lo que hay detrás. Un lenguaje visual para productos que quieren sentirse vivos.", primary = "Empezar", secondary = "Ver ejemplos", href = "#", variant = "regular", scheme = "dark", className = "", style }: HeroLiquidGlassProps) {
function move(event: PointerEvent<HTMLElement>) {
if (event.pointerType === "touch" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const rect = event.currentTarget.getBoundingClientRect();
event.currentTarget.style.setProperty("--px", String(((event.clientX - rect.left) / rect.width - 0.5) * 2));
event.currentTarget.style.setProperty("--py", String(((event.clientY - rect.top) / rect.height - 0.5) * 2));
}
function leave(event: PointerEvent<HTMLElement>) {
event.currentTarget.style.setProperty("--px", "0");
event.currentTarget.style.setProperty("--py", "0");
}
return (
<section className={`gchlq-root ${className}`} style={style} data-s={scheme} onPointerMove={move} onPointerLeave={leave}>
<style>{CSS}</style>
<div className="gchlq-blob gchlq-b1" />
<div className="gchlq-blob gchlq-b2" />
<div className="gchlq-blob gchlq-b3" />
<div className="gchlq-blob gchlq-b4" />
<div className="gchlq-lines" aria-hidden="true" />
<div className="gchlq-layer" style={{ "--d": 10 } as CSSProperties} aria-hidden="true">
<Surface className="gchlq-orb" variant="clear" bezel={46} depth={58}>
<span className="gchlq-hl" />
</Surface>
</div>
<div className="gchlq-layer" style={{ "--d": -6 } as CSSProperties} aria-hidden="true">
<Surface className="gchlq-pill" bezel={22} depth={24} elevated>
<i /> Sistema en línea
</Surface>
<Surface className="gchlq-stat" variant="clear" bezel={30} depth={30} elevated>
<b>99.9%</b>
<span>disponibilidad (dato de muestra)</span>
</Surface>
</div>
<Surface as="nav" className="gchlq-nav" aria-label="Secciones de ejemplo" variant="clear" bezel={20} depth={20}>
<a href={href}>Inicio</a>
<a href={href}>Producto</a>
<a href={href}>Precios</a>
</Surface>
<div className="gchlq-copy">
<h1 className="gchlq-title">{title}</h1>
<p className="gchlq-desc">{description}</p>
<div className="gchlq-actions">
<Surface as="a" className="gchlq-btn" href={href} variant={variant} scheme={scheme} tone="solid" interactive bezel={20} depth={20}>
{primary}
</Surface>
<Surface as="a" className="gchlq-btn" href={href} variant="clear" interactive bezel={20} depth={22}>
{secondary}
</Surface>
</div>
</div>
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/hero-liquid-glassEjemplo de uso de Hero liquid glass
Después de copiar hero-liquid-glass.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { HeroLiquidGlass } from "./hero-liquid-glass";
export default function Ejemplo() {
return <HeroLiquidGlass variant="regular" title="Ejemplo" />;
}Propiedades de HeroLiquidGlass
HeroLiquidGlass acepta 9 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
description? | string | Opcional. |
primary? | string | Opcional. |
secondary? | string | Opcional. |
href? | string | Opcional. |
variant? | "regular" | "clear" | Opcional. |
scheme? | "dark" | "light" | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Variantes disponibles
variant:regular,clearscheme:dark,light
Personaliza Hero liquid glass con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gchlq-bg | #130b3d |
--gchlq-blur | 2.6px |
--gchlq-fg | #fff |
--gchlq-clear-blur | .45px |
--gchlq-r | 24px |
--px | 0 |
--py | 0 |
Accesibilidad y rendimiento
El archivo tiene 349 líneas (24 KB) y define 4 animaciones con @keyframes. Esto es lo que hace Hero liquid glass por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Usa backdrop-filter para el efecto de vidrio; en navegadores sin soporte el panel se ve sin desenfoque.
- Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
- Usa @property de CSS para animar valores que normalmente no se interpolan.
- Es un componente de cliente de Next.js: incluye la directiva "use client".