Exclusivobeta

Surface <mv-surface-layer>

Un sistema de materiales en GPU para cualquier elemento: data-mv-surface="glass | chrome | jelly | velvet | holo". Un único contexto WebGL2 renderiza todas las superficies en una sola llamada de dibujo instanciada (atlas compartido), cada una recortada por un SDF de esquinas redondeadas leído de su border-radius: reflejos especulares que siguen al puntero, reflejos de estudio en el cromo, gelatina que tiembla sobre un resorte al hacer clic, terciopelo que se aplasta bajo tu dedo, lámina holográfica brillante. Decenas de elementos, un contexto, y un fallback CSS por material.

CategoríaEfectos
TipoWeb Component (<mv-surface-layer>)
Estadobeta
Keywordsexclusive, webgl, material, glass, chrome, jelly, velvet, holographic, sdf, instancing, atlas, pointer

When to use

  • Buttons, cards or tiles need tactile materials like chrome, jelly, velvet or holographic foil
  • Many elements on one page need pointer-reactive specular lighting from a single WebGL context
  • A playful landing or product showcase should react to clicks with a springy jelly wobble

Avoid when

  • A static frosted panel over a background is enough, with no WebGL → use Glass instead
  • The interface is a dense data app where decorative GPU materials distract and drain battery

Instalación

node scripts/add.mjs surface --out ./src/marvelous

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["surface"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/canvas.js, core/dom.js, core/element.js, core/motion.js, core/webgl.js, components/surface/surface.js, components/surface/surface.css.

Uso

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

<div id="sf-demo" class="sf-demo">
  <style>
    .sf-demo {
      --sf-bg1: light-dark(oklch(0.78 0.16 300), oklch(0.36 0.14 295));
      --sf-bg2: light-dark(oklch(0.84 0.13 205), oklch(0.34 0.1 220));
      --sf-bg3: light-dark(oklch(0.88 0.13 75), oklch(0.36 0.1 40));
      --sf-bg4: light-dark(oklch(0.82 0.14 350), oklch(0.3 0.12 345));
      position: relative; min-height: 600px; padding: 26px clamp(16px, 3vw, 34px) 30px; box-sizing: border-box; overflow: hidden;
      background:
        radial-gradient(42% 60% at 14% 26%, var(--sf-bg1), transparent 72%),
        radial-gradient(40% 60% at 90% 14%, var(--sf-bg2), transparent 72%),
        radial-gradient(45% 55% at 66% 86%, var(--sf-bg3), transparent 72%),
        radial-gradient(40% 55% at 22% 90%, var(--sf-bg4), transparent 72%),
        light-dark(oklch(0.97 0.01 280), oklch(0.17 0.02 280));
      color: var(--mv-fg);
      font-family: var(--mv-font-sans);
    }
    .sf-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
    .sf-brand { display: flex; align-items: center; gap: 10px; font: 750 1.05rem/1 var(--mv-font-sans); letter-spacing: -.02em; margin-right: auto; }
    .sf-logo { width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; font: 800 15px/1 var(--mv-font-sans); }
    .sf-nav { display: flex; gap: 6px; padding: 5px; border-radius: 999px; }
    .sf-nav a { padding: 8px 14px; border-radius: 999px; font: 600 13px/1 var(--mv-font-sans); color: inherit; text-decoration: none; opacity: .75; }
    .sf-nav a[aria-current] { opacity: 1; }
    .sf-icon { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; cursor: pointer; padding: 0; }
    .sf-icon svg { width: 18px; height: 18px; }
    .sf-grid { display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 18px; margin-top: 22px; }
    @media (max-width: 980px) { .sf-grid { grid-template-columns: 1fr 1fr; } }
    @media (max-width: 640px) { .sf-grid { grid-template-columns: 1fr; } }
    .sf-col { display: grid; gap: 18px; align-content: start; }
    .sf-card { border-radius: 24px; padding: 20px 22px; }
    .sf-label { font: 600 12px/1.2 var(--mv-font-sans); letter-spacing: .02em; opacity: .72; }
    .sf-big { font: 760 2.5rem/1 var(--mv-font-sans); letter-spacing: -.045em; margin-top: 10px; font-variant-numeric: tabular-nums; }
    .sf-delta { display: inline-flex; gap: 6px; align-items: center; margin-top: 10px; font: 600 12px/1 var(--mv-font-sans); padding: 5px 9px; border-radius: 999px; background: light-dark(oklch(0.93 0.08 150 / .7), oklch(0.45 0.1 150 / .45)); color: light-dark(oklch(0.38 0.1 150), oklch(0.9 0.08 150)); }
    .sf-spark { display: block; width: 100%; height: 70px; margin-top: 12px; }
    .sf-mini { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
    .sf-mini .sf-big { font-size: 1.6rem; }
    .sf-titane { aspect-ratio: 1.586; border-radius: 22px; padding: 22px; display: flex; flex-direction: column; justify-content: space-between; }
    .sf-titane-top { display: flex; justify-content: space-between; align-items: center; font: 750 13px/1 var(--mv-font-sans); letter-spacing: .14em; text-transform: uppercase; }
    .sf-chip { width: 44px; height: 34px; border-radius: 8px; background: linear-gradient(135deg, #e9d59a, #b8943f 60%, #f1e2ae); box-shadow: inset 0 0 0 1px oklch(0.4 0.05 80 / .45); }
    .sf-number { font: 600 1.25rem/1 var(--mv-font-mono); letter-spacing: .12em; }
    .sf-titane-foot { display: flex; justify-content: space-between; font: 600 11px/1.3 var(--mv-font-sans); letter-spacing: .08em; text-transform: uppercase; opacity: .8; }
    .sf-actions { display: flex; gap: 12px; flex-wrap: wrap; }
    .sf-jelly { border: 0; border-radius: 999px; padding: 0 22px; height: 48px; font: 700 14px/1 var(--mv-font-sans); cursor: pointer; }
    .sf-jelly:focus-visible, .sf-icon:focus-visible, .sf-debug:focus-visible { outline: 2px solid var(--mv-ring); outline-offset: 3px; }
    .sf-ticket { border-radius: 22px; padding: 22px; display: grid; gap: 14px; }
    .sf-ticket h3, .sf-velvet h3 { margin: 0; font: 760 1.35rem/1.1 var(--mv-font-sans); letter-spacing: -.03em; }
    .sf-ticket-row { display: flex; justify-content: space-between; font: 600 12px/1.3 var(--mv-font-sans); }
    .sf-ticket-row b { display: block; font-size: 15px; margin-top: 3px; }
    .sf-perf { border-top: 2px dashed currentColor; opacity: .25; margin: 2px -4px; }
    .sf-velvet { border-radius: 24px; padding: 24px 22px; min-height: 176px; display: flex; flex-direction: column; justify-content: space-between; cursor: crosshair; }
    .sf-velvet p { margin: 8px 0 0; font-size: 13px; opacity: .8; max-width: 26ch; }
    .sf-velvet small { font: 600 11px/1 var(--mv-font-mono); letter-spacing: .1em; text-transform: uppercase; opacity: .75; }
    .sf-foot { display: flex; align-items: center; gap: 12px; margin-top: 20px; flex-wrap: wrap; }
    .sf-debug { border: 0; border-radius: 999px; height: 38px; padding: 0 16px; font: 600 12.5px/1 var(--mv-font-sans); cursor: pointer; color: inherit; }
    .sf-debug[aria-pressed="true"] { font-weight: 750; }
    .sf-note { font-size: 12px; opacity: .7; }
  </style>

  <mv-surface-layer id="sf-layer"></mv-surface-layer>

  <header class="sf-top">
    <div class="sf-brand"><span class="sf-logo" data-mv-surface="chrome" aria-hidden="true">N</span>Nimbus Studio</div>
    <nav class="sf-nav" data-mv-surface="glass" aria-label="Sections">
      <a href="#" aria-current="page">Overview</a><a href="#">Orders</a><a href="#">Customers</a>
    </nav>
    <button class="sf-icon" data-mv-surface="chrome" type="button" aria-label="Notifications">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
    </button>
    <button class="sf-icon" data-mv-surface="chrome" type="button" aria-label="Settings">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>
    </button>
  </header>

  <div class="sf-grid">
    <div class="sf-col">
      <section class="sf-card" data-mv-surface="glass" aria-label="Revenue">
        <div class="sf-label">Revenue · September</div>
        <div class="sf-big">$48,290</div>
        <span class="sf-delta">▲ 12.4% vs. August</span>
        <svg class="sf-spark" viewBox="0 0 300 70" preserveAspectRatio="none" aria-hidden="true">
          <defs><linearGradient id="sf-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".22"/><stop offset="1" stop-color="currentColor" stop-opacity="0"/></linearGradient></defs>
          <path d="M0 52 C20 50 30 40 50 42 S80 56 100 44 130 20 150 26 180 44 200 34 230 12 250 16 280 8 300 6 L300 70 L0 70 Z" fill="url(#sf-fill)"/>
          <path d="M0 52 C20 50 30 40 50 42 S80 56 100 44 130 20 150 26 180 44 200 34 230 12 250 16 280 8 300 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
        </svg>
      </section>
      <div class="sf-mini">
        <section class="sf-card" data-mv-surface="glass"><div class="sf-label">Active carts</div><div class="sf-big">312</div></section>
        <section class="sf-card" data-mv-surface="glass"><div class="sf-label">Conversion rate</div><div class="sf-big">3.8%</div></section>
      </div>
    </div>

    <div class="sf-col">
      <section class="sf-titane" data-mv-surface="chrome" aria-label="Titanium card">
        <div class="sf-titane-top"><span>Titanium</span><span>Pro</span></div>
        <div class="sf-chip" aria-hidden="true"></div>
        <div class="sf-number">•••• •••• •••• 4821</div>
        <div class="sf-titane-foot"><span>Jordan Lee</span><span>Exp. 09/29</span></div>
      </section>
      <div class="sf-actions">
        <button class="sf-jelly" data-mv-surface="jelly" type="button">Publish</button>
        <button class="sf-jelly" data-mv-surface="jelly" type="button" style="--mv-surface-tint: oklch(0.66 0.14 175)">Schedule</button>
        <button class="sf-jelly" data-mv-surface="jelly" type="button" style="--mv-surface-tint: oklch(0.7 0.17 35)">Archive</button>
      </div>
    </div>

    <div class="sf-col">
      <section class="sf-ticket" data-mv-surface="holo" aria-label="Preview pass">
        <div class="sf-label">Preview pass</div>
        <h3>Design Week London</h3>
        <div class="sf-perf" aria-hidden="true"></div>
        <div class="sf-ticket-row"><div>Date<b>Nov 14</b></div><div>Entry<b>Hall B</b></div><div>Seat<b>VIP 027</b></div></div>
      </section>
      <section class="sf-velvet" data-mv-surface="velvet" aria-label="Velvet Collection">
        <small>Limited edition</small>
        <div><h3>Velvet Collection</h3><p>Run your finger across it: the velvet lies down, then springs back up.</p></div>
      </section>
    </div>
  </div>

  <div class="sf-foot">
    <button class="sf-debug" data-mv-surface="glass" type="button" aria-pressed="false" data-sf-debug>Show shared canvas</button>
    <span class="sf-note">14 surfaces, 5 materials, a single WebGL2 context.</span>
  </div>

  <script type="module">
    const root = document.getElementById("sf-demo");
    const btn = root.querySelector("[data-sf-debug]");
    btn.addEventListener("click", () => {
      const on = btn.getAttribute("aria-pressed") !== "true";
      btn.setAttribute("aria-pressed", String(on));
      btn.textContent = on ? "Hide shared canvas" : "Show shared canvas";
      root.querySelector("#sf-layer").toggleAttribute("debug", on);
    });
  </script>
</div>

API

Attributes

NameTipoDefaultDescription
data-mv-surface (on any element)glass | chrome | jelly | velvet | holoEnables the material; detected automatically (added, removed, value changed). Put it on container elements (not <input>/<img>).
dprnumber2<mv-surface-layer>: maximum atlas density (lowered automatically if it exceeds 4096 px).
debugboolean<mv-surface-layer>: shows the shared canvas (atlas of body + light tiles) and the surface count.
animateboolean<mv-surface-layer>: continuous rendering (otherwise the loop stops as soon as everything is at rest).
data-mv-surface-liveread-onlySet when GPU rendering is active on the element (otherwise only the CSS fallback applies).

Properties

NameDescription
<mv-surface-layer>Optional: created automatically with the first surface. Placing it yourself lets you set dpr/debug/animate.

CSS classes

NameDescription
mv-surface-body2D canvas inserted as the first child, under the content (the material body).
mv-surface-light2D canvas inserted as the last child, above the content (speculars, rims, glitter).
mv-surface-atlas / -captionShared WebGL2 canvas and its caption (visible with debug).

CSS variables

NameDescription
--mv-surface-tintMaterial tint, per element (tokens accepted, re-read on theme change). Defaults: white (glass), silver (chrome), accent (jelly), raspberry (velvet), pearl (holo).
--mv-surface-inkText color suited to the material (dark on light chrome/holo, light on jelly/velvet).
--mv-surface-x / --mv-surface-yPointer position on the element (%), used by the CSS fallback.

Accessibility

Purely decorative: the canvases are aria-hidden and pointer-events: none; the element’s content, focus and semantics stay intact. Keyboard: Enter/Space on a focused element triggers the same reaction as a click (ripple, spring). Reduced motion: no jelly wobble, ripple or velvet trail; lighting still follows the pointer but without inertia. Per-material inks are chosen for contrast (check with your own tints). Without WebGL2 (or with a lost context): a CSS approximation of each material, which also follows the pointer.

Esta página se tradujo con IA. Informar de un problema de traducción