Ir al contenido
Evaluar mi proyecto
Componentes · Mundos de estiloDisponible

Mundo liquid glass: centro de control

Mosaicos de vidrio estilo iOS 26 con conectividad, brillo y volumen verticales, concentración y música sobre fondo vivo.

Material
Fondo
Horizonte nocturnoMarea Alta

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.

mundo-liquid-glass.tsx329 líneas
Descargar
"use client";

import { createElement, useCallback, useEffect, useId, useState, useSyncExternalStore, type CSSProperties, type PointerEvent, type ReactNode } from "react";

/** Mundo liquid glass: centro de control estilo iOS 26 con mosaicos de cristal (conectividad, brillo y volumen verticales, modo concentración, linterna y música) que refractan un fondo vivo. Cada mosaico se comprime con elasticidad al presionar. React + TypeScript, sin dependencias. */
export type MundoLiquidGlassProps = {
  variant?: "regular" | "clear";
  scheme?: "dark" | "light";
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcmlg-root { box-sizing: border-box; container-type: inline-size; position: relative; isolation: isolate; overflow: hidden; width: 100%; padding: clamp(18px, 4cqi, 40px); background: #120a38; color: #fff; font: 600 13px/1.25 -apple-system, "SF Pro Display", ui-sans-serif, system-ui, sans-serif; text-shadow: 0 1px 2px rgba(10, 5, 50, .35); }
.gcmlg-root *, .gcmlg-root *::before, .gcmlg-root *::after { box-sizing: border-box; }
.gcmlg-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcmlg-root :focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.gcmlg-root[data-s="light"] { color: #17202b; text-shadow: none; }
.gcmlg-root[data-s="light"] { background: var(--gcmlg-bg,#e4eaf0); }
@property --gcmlg-a { syntax: "<angle>"; inherits: true; initial-value: 135deg; }
@property --gcmlg-p { syntax: "<angle>"; inherits: true; initial-value: 0deg; }
@property --gcmlg-sx { syntax: "<percentage>"; inherits: true; initial-value: 24%; }
@property --gcmlg-sy { syntax: "<percentage>"; inherits: true; initial-value: 8%; }
@property --gcmlg-hi { syntax: "<number>"; inherits: true; initial-value: .78; }
@property --gcmlg-tc { syntax: "<color>"; inherits: true; initial-value: transparent; }
@property --gcmlg-te { syntax: "<color>"; inherits: true; initial-value: transparent; }
.gcmlg-g, .gcmlg-gf {
  --gcmlg-a: 135deg; --gcmlg-p: 0deg; --gcmlg-sx: 24%; --gcmlg-sy: 8%;
  --gcmlg-bl: var(--gcmlg-blur, 2.6px); --gcmlg-tc: rgba(255,255,255,.025); --gcmlg-te: rgba(255,255,255,.12);
  --gcmlg-sh: rgba(12,16,24,.16); --gcmlg-hi: .78; --gcmlg-ink: 12,16,24; --gcmlg-ins: 3px;
  --gcmlg-contact: .14; --gcmlg-ambient: .2;
  position: relative; isolation: isolate; border: 0; color: var(--gcmlg-fg, #fff);
  background: radial-gradient(115% 120% at 50% 40%, var(--gcmlg-tc) 42%, var(--gcmlg-te) 100%), linear-gradient(var(--gcmlg-sh),var(--gcmlg-sh));
  -webkit-backdrop-filter: blur(var(--gcmlg-bl)) saturate(1.08) brightness(1.02);
  backdrop-filter: blur(var(--gcmlg-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(--gcmlg-ink),.26), 0 1px 2px rgba(var(--gcmlg-ink),var(--gcmlg-contact)), 0 12px 28px -10px rgba(var(--gcmlg-ink),var(--gcmlg-ambient));
  transition: --gcmlg-a 280ms cubic-bezier(.16,1,.3,1), --gcmlg-p 320ms cubic-bezier(.22,1.08,.36,1), --gcmlg-sx 240ms cubic-bezier(.16,1,.3,1), --gcmlg-sy 240ms cubic-bezier(.16,1,.3,1), --gcmlg-hi 180ms ease-out, --gcmlg-tc 180ms ease-out, --gcmlg-te 180ms ease-out, box-shadow 240ms ease-out, transform 360ms cubic-bezier(.22,1.08,.36,1);
}
.gcmlg-g[data-v="clear"], .gcmlg-gf[data-v="clear"] { --gcmlg-bl: var(--gcmlg-clear-blur,.45px); --gcmlg-tc: rgba(255,255,255,.008); --gcmlg-te: rgba(255,255,255,.065); --gcmlg-sh: rgba(12,16,24,.07); }
.gcmlg-root[data-s="light"] :is(.gcmlg-g,.gcmlg-gf), .gcmlg-g[data-s="light"], .gcmlg-gf[data-s="light"] { --gcmlg-tc: rgba(255,255,255,.06); --gcmlg-te: rgba(255,255,255,.18); --gcmlg-sh: rgba(255,255,255,.025); --gcmlg-ink: 24,30,38; --gcmlg-fg: #17202b; --gcmlg-contact: .09; --gcmlg-ambient: .13; }
.gcmlg-root[data-s="light"] :is(.gcmlg-g,.gcmlg-gf)[data-v="clear"], .gcmlg-g[data-s="light"][data-v="clear"], .gcmlg-gf[data-s="light"][data-v="clear"] { --gcmlg-tc: rgba(255,255,255,.015); --gcmlg-te: rgba(255,255,255,.085); }
/* Nested controls share their parent's optical plane; never blur glass twice. */
.gcmlg-gf { -webkit-backdrop-filter: none; backdrop-filter: none; --gcmlg-tc: rgba(255,255,255,.04); --gcmlg-te: rgba(255,255,255,.12); --gcmlg-sh: transparent; }
.gcmlg-g[data-r="1"] { backdrop-filter: blur(var(--gcmlg-bl)) var(--gcmlg-f) saturate(1.08) brightness(1.02); }
.gcmlg-g[data-t="solid"], .gcmlg-gf[data-t="solid"] { --gcmlg-tc: rgba(255,255,255,.19); --gcmlg-te: rgba(255,255,255,.32); --gcmlg-sh: rgba(12,16,24,.2); font-weight: 650; }
.gcmlg-g[data-t="solid"][data-s="light"], .gcmlg-gf[data-t="solid"][data-s="light"] { --gcmlg-sh: rgba(255,255,255,.08); }
.gcmlg-g::before, .gcmlg-gf::before { content: ""; position: absolute; inset: 0; z-index: 3; padding: .8px; border-radius: inherit; pointer-events: none; opacity: var(--gcmlg-hi);
  background: conic-gradient(from calc(var(--gcmlg-a) + var(--gcmlg-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;
}
.gcmlg-g::after, .gcmlg-gf::after { content: ""; position: absolute; inset: var(--gcmlg-ins); z-index: -1; border-radius: max(0px,calc(var(--gcmlg-r,24px) - var(--gcmlg-ins))); pointer-events: none; opacity: .55; transition: opacity 180ms ease-out;
  background: radial-gradient(38% 24% at var(--gcmlg-sx) var(--gcmlg-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(--gcmlg-ink),.12);
}
.gcmlg-root[data-s="light"] :is(.gcmlg-g,.gcmlg-gf)::before, .gcmlg-g[data-s="light"]::before, .gcmlg-gf[data-s="light"]::before { background: conic-gradient(from calc(var(--gcmlg-a) + var(--gcmlg-p)), rgba(var(--gcmlg-ink),.08), rgba(255,255,255,.3) 12%, #fff 21%, rgba(255,255,255,.25) 28%, rgba(var(--gcmlg-ink),.13) 45%, rgba(var(--gcmlg-ink),.03) 60%, rgba(255,255,255,.95) 72%, rgba(255,255,255,.3) 79%, rgba(var(--gcmlg-ink),.08)); }
.gcmlg-g[data-e="2"] { --gcmlg-contact: .17; --gcmlg-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(--gcmlg-ink),.24), 0 2px 4px rgba(var(--gcmlg-ink),var(--gcmlg-contact)), 0 24px 52px -18px rgba(var(--gcmlg-ink),var(--gcmlg-ambient)); }
.gcmlg-g[data-i], .gcmlg-gf[data-i] { cursor: pointer; touch-action: manipulation; }
.gcmlg-g:focus-visible, .gcmlg-gf:focus-visible { outline: 2px solid var(--gcmlg-fg,#fff); outline-offset: 4px; }
@media (hover: hover) and (pointer: fine) {
  .gcmlg-g[data-i]:hover:not(:disabled), .gcmlg-gf[data-i]:hover:not(:disabled) { --gcmlg-hi: .98; }
  .gcmlg-g[data-i]:hover:not(:disabled)::after, .gcmlg-gf[data-i]:hover:not(:disabled)::after { opacity: .85; }
  .gcmlg-g[data-t="solid"][data-i]:hover:not(:disabled), .gcmlg-gf[data-t="solid"][data-i]:hover:not(:disabled) { --gcmlg-tc: rgba(255,255,255,.25); }
}
.gcmlg-g[data-i]:active:not(:disabled), .gcmlg-gf[data-i]:active:not(:disabled) { --gcmlg-p: 14deg; --gcmlg-hi: 1; transform: scale(.985,.97); transition-duration: 80ms; }
.gcmlg-g[data-i]:active:not(:disabled)::after, .gcmlg-gf[data-i]:active:not(:disabled)::after { opacity: .95; }
.gcmlg-g:disabled, .gcmlg-gf:disabled { cursor: not-allowed; }
.gcmlg-svg { position: absolute; width: 0; height: 0; overflow: hidden; pointer-events: none; }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .gcmlg-g { background: rgba(30,36,46,.9); }
  .gcmlg-root[data-s="light"] .gcmlg-g, .gcmlg-g[data-s="light"] { background: rgba(245,247,250,.94); }
}
@media (prefers-reduced-transparency: reduce) { .gcmlg-g, .gcmlg-gf { background: #26303d; color: #fff; -webkit-backdrop-filter: none; backdrop-filter: none; } .gcmlg-root[data-s="light"] :is(.gcmlg-g,.gcmlg-gf), .gcmlg-g[data-s="light"], .gcmlg-gf[data-s="light"] { background: #f2f5f8; color: #17202b; } }
@media (prefers-reduced-motion: reduce) {
  .gcmlg-root *, .gcmlg-root *::before, .gcmlg-root *::after { animation: none !important; transition-duration: .01ms !important; }
  .gcmlg-g[data-i]:active:not(:disabled), .gcmlg-gf[data-i]:active:not(:disabled) { transform: none; }
}
@media (forced-colors: active) { .gcmlg-g, .gcmlg-gf { border: 1px solid CanvasText; background: Canvas; color: CanvasText; -webkit-backdrop-filter: none; backdrop-filter: none; } .gcmlg-g::before, .gcmlg-g::after, .gcmlg-gf::before, .gcmlg-gf::after { display: none; } }

.gcmlg-bg { position: absolute; inset: 0; z-index: -1; }
.gcmlg-bg i { position: absolute; border-radius: 50%; filter: blur(34px); animation: gcmlg-a 12s ease-in-out infinite alternate; }
.gcmlg-bg i:nth-child(1) { width: 280px; height: 280px; left: -40px; top: -40px; background: #ff4fb8; }
.gcmlg-bg i:nth-child(2) { width: 300px; height: 300px; right: -60px; top: 20px; background: #2ec8ff; animation-duration: 15s; }
.gcmlg-bg i:nth-child(3) { width: 260px; height: 260px; left: 30%; bottom: -80px; background: #ffc857; animation-duration: 18s; }
.gcmlg-bg b { position: absolute; inset: 0; background: repeating-linear-gradient(90deg, transparent 0 47px, rgba(255, 255, 255, .12) 47px 48px), repeating-linear-gradient(0deg, transparent 0 47px, rgba(255, 255, 255, .12) 47px 48px); }
.gcmlg-grid { position: relative; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 92px; gap: 12px; max-width: 420px; margin: 0 auto; }
.gcmlg-t { display: grid; padding: 12px; border-radius: 28px; text-align: left; }
.gcmlg-conn { grid-column: span 2; grid-row: span 2; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; padding: 10px; gap: 8px; cursor: default; }
.gcmlg-cb { display: grid; place-items: center; border-radius: 999px; }
.gcmlg-root .gcmlg-cb[aria-pressed="true"] { --gcmlg-tc: rgba(47,140,255,.52); --gcmlg-te: rgba(79,159,255,.65); --gcmlg-ink: 0, 40, 120; color: #fff; }
.gcmlg-root .gcmlg-cb[aria-pressed="true"][data-g="true"] { --gcmlg-tc: rgba(48,209,88,.52); --gcmlg-te: rgba(82,222,116,.65); --gcmlg-ink: 0, 70, 20; }
.gcmlg-music { grid-column: span 2; align-content: space-between; }
.gcmlg-music small, .gcmlg-t small { display: block; font-weight: 500; opacity: .9; }
.gcmlg-sl { grid-row: span 2; padding: 0; overflow: hidden; align-items: end; }
.gcmlg-slf { position: absolute; left: 0; right: 0; bottom: 0; height: 100%; z-index: 1; border-radius: 0 0 28px 28px; background: linear-gradient(180deg, rgba(255,255,255,.4), rgba(255,255,255,.22)); box-shadow: inset 0 1.5px 0 #fff, 0 -2px 8px -2px rgba(8, 6, 40, .25); transform-origin: bottom; transition: transform 100ms ease-out; }
.gcmlg-sli { position: absolute; z-index: 2; left: 50%; bottom: 14px; transform: translateX(-50%); color: inherit; }
.gcmlg-sl input { position: absolute; z-index: 4; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ns-resize; -webkit-appearance: slider-vertical; appearance: slider-vertical; writing-mode: vertical-lr; direction: rtl; }
.gcmlg-sl:focus-within { outline: 2px solid #fff; outline-offset: 3px; }
.gcmlg-root[data-s="light"] .gcmlg-sl:focus-within { outline-color: #17202b; }
@keyframes gcmlg-a { to { transform: translate(30px, 40px) scale(1.15); } }
@container (max-width: 400px) { .gcmlg-grid { grid-auto-rows: 82px; } }
@media (prefers-reduced-motion: reduce) { .gcmlg-root * { animation: none !important; } }`;

/* 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 = `gcmlg-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("--gcmlg-a", `${135 + (x - 0.5) * 40 - (y - 0.5) * 18}deg`);
      event.currentTarget.style.setProperty("--gcmlg-sx", `${18 + x * 64}%`);
      event.currentTarget.style.setProperty("--gcmlg-sy", `${3 + y * 12}%`);
    }
    onPointerMove?.(event);
  };
  const leave = (event: PointerEvent<HTMLElement>) => {
    for (const name of ["a", "sx", "sy"]) event.currentTarget.style.removeProperty(`--gcmlg-${name}`);
    onPointerLeave?.(event);
  };

  return createElement(
    as,
    {
      ...rest,
      ref: bind,
      className: `gcmlg-${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, "--gcmlg-r": live ? `${lens.r}px` : radius !== undefined ? `${radius}px` : undefined, "--gcmlg-f": live ? `url(#${id})` : undefined } as CSSProperties,
      onPointerMove: move,
      onPointerLeave: leave,
    },
    live ? (
      <svg className="gcmlg-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,
  );
}

const I = ({ d }: { d: string }) => (
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d={d} />
  </svg>
);

export function MundoLiquidGlass({ variant = "regular", scheme = "dark", className = "", style }: MundoLiquidGlassProps) {
  const [on, setOn] = useState({ avion: false, datos: true, wifi: true, bt: true, foco: false, linterna: false });
  const [bright, setBright] = useState(65);
  const [vol, setVol] = useState(40);
  const tog = (k: keyof typeof on) => setOn((p) => ({ ...p, [k]: !p[k] }));
  return (
    <div className={`gcmlg-root ${className}`} style={style} data-s={scheme}>
      <style>{CSS}</style>
      <div className="gcmlg-bg" aria-hidden="true"><i /><i /><i /><b /></div>
      <div className="gcmlg-grid">
        <Surface className="gcmlg-t gcmlg-conn" role="group" aria-label="Conectividad" variant={variant} elevated bezel={26} depth={26}>
          <Surface as="button" flat interactive className="gcmlg-cb" type="button" aria-pressed={on.avion} aria-label="Modo avión" onClick={() => tog("avion")}><I d="M21 16v-2l-8-5V3.500a1.500 1.500 0 0 0-3 0V9l-8 5v2l8-2.500V19l-2 1.500V22l3.500-1 3.500 1v-1.500L13 19v-5.500z" /></Surface>
          <Surface as="button" flat interactive className="gcmlg-cb" data-g="true" type="button" aria-pressed={on.datos} aria-label="Datos móviles" onClick={() => tog("datos")}><I d="M5 20V15M10 20V10M15 20V6M20 20V3" /></Surface>
          <Surface as="button" flat interactive className="gcmlg-cb" type="button" aria-pressed={on.wifi} aria-label="Wi-Fi" onClick={() => tog("wifi")}><I d="M2 9a15 15 0 0 1 20 0M5 12.500a10 10 0 0 1 14 0M8.500 16a5 5 0 0 1 7 0M12 19.500h.01" /></Surface>
          <Surface as="button" flat interactive className="gcmlg-cb" type="button" aria-pressed={on.bt} aria-label="Bluetooth" onClick={() => tog("bt")}><I d="m7 7 10 10-5 5V2l5 5L7 17" /></Surface>
        </Surface>
        <Surface className="gcmlg-t gcmlg-sl" variant={variant} bezel={24} depth={24}>
          <span className="gcmlg-slf" style={{ transform: `scaleY(${bright / 100})` }} aria-hidden="true" />
          <span className="gcmlg-sli" aria-hidden="true"><I d="M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M4.900 4.900l1.400 1.400M17.700 17.700l1.400 1.400M2 12h2M20 12h2M4.900 19.100l1.400-1.400M17.700 6.300l1.400-1.400" /></span>
          <input type="range" min={5} max={100} value={bright} aria-label="Brillo" onChange={(e) => setBright(Number(e.target.value))} />
        </Surface>
        <Surface className="gcmlg-t gcmlg-sl" variant={variant} bezel={24} depth={24}>
          <span className="gcmlg-slf" style={{ transform: `scaleY(${vol / 100})` }} aria-hidden="true" />
          <span className="gcmlg-sli" aria-hidden="true"><I d="M11 5 6 9H2v6h4l5 4zM15.500 8.500a5 5 0 0 1 0 7" /></span>
          <input type="range" min={0} max={100} value={vol} aria-label="Volumen" onChange={(e) => setVol(Number(e.target.value))} />
        </Surface>
        <Surface as="button" className="gcmlg-t gcmlg-music" type="button" aria-pressed={on.foco} variant={variant} tone={on.foco ? "solid" : undefined} interactive bezel={24} depth={24} onClick={() => tog("foco")}>
          <span>Concentración<small>{on.foco ? "Activada hasta las 6:00 p. m." : "Desactivada"}</small></span>
        </Surface>
        <Surface as="button" className="gcmlg-t" type="button" aria-pressed={on.linterna} aria-label="Linterna" variant={variant} tone={on.linterna ? "solid" : undefined} interactive bezel={24} depth={24} style={{ placeItems: "center" }} onClick={() => tog("linterna")}>
          <I d="M9 2h6v5l2 3v12H7V10l2-3zM9 7h6" />
        </Surface>
        <Surface className="gcmlg-t gcmlg-music" role="group" aria-label="Música" variant={variant} bezel={24} depth={24} style={{ cursor: "default" }}>
          <span>Horizonte nocturno<small>Marea Alta</small></span>
        </Surface>
      </div>
    </div>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/mundo-liquid-glass

Ejemplo de uso de Mundo liquid glass: centro de control

Después de copiar mundo-liquid-glass.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { MundoLiquidGlass } from "./mundo-liquid-glass";

export default function Ejemplo() {
  return <MundoLiquidGlass variant="regular" />;
}

Propiedades de MundoLiquidGlass

MundoLiquidGlass acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
variant?"regular" | "clear"Opcional.
scheme?"dark" | "light"Opcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Variantes disponibles

  • variant: regular, clear
  • scheme: dark, light

Personaliza Mundo liquid glass: centro de control 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.

VariableValor por defecto
--gcmlg-bg#e4eaf0
--gcmlg-blur2.6px
--gcmlg-fg#fff
--gcmlg-clear-blur.45px
--gcmlg-r24px

Accesibilidad y rendimiento

El archivo tiene 329 líneas (23 KB) y define 1 animación con @keyframes. Esto es lo que hace Mundo liquid glass: centro de control 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).
  • Declara roles ARIA para lectores de pantalla: group.
  • 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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)