独家

Adaptive Ink <mv-adaptive-ink>

在任何动画背景(WebGL 着色器、视频、canvas、图片)上都保持可读的文字:实时采样每个单词下方的背景,切分成列,每一列通过交叉淡入淡出选择浅色或深色墨水,一个字母可以一半白、一半黑。

工作原理

在任何动画背景(WebGL 着色器、视频、canvas、图片)上都保持可读的文字:实时采样每个单词下方的背景,切分成若干列,每一列通过交叉淡变选择浅色或深色墨水,因此一个字母可以一半白、一半黑。光晕和额外的字重用来挽救灰色区域;实测的 WCAG 对比度会对外暴露,并且可以可视化显示。

分类动画文字
类型Web Component(<mv-adaptive-ink>)
状态稳定版
Keywordsexclusive, contrast, wcag, readability, text, video, shader, luminance, adaptive, a11y

When to use

  • Text over a shader, video, canvas or image background must stay readable everywhere
  • Measured WCAG contrast of text over a moving backdrop must be exposed or visualized for audits

Avoid when

  • The background is a solid color or known static gradient; a fixed text color is simpler and cheaper
  • The backdrop is cross-origin media without CORS; sampling fails and it falls back to difference blending

安装

node scripts/add.mjs adaptive-ink --out ./src/marvelous

通过 Marvelous UI MCP 服务器使用 AI 智能体:install_components({ slugs: ["adaptive-ink"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css、core/base.css、core/canvas.js、core/dom.js、core/element.js、core/motion.js、components/adaptive-ink/adaptive-ink.js、components/adaptive-ink/adaptive-ink.css。

用法

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="ai-demo" class="ai-demo">
  <style>
    .ai-demo { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; padding: 18px; min-height: 640px; box-sizing: border-box; }
    .ai-scene { position: relative; overflow: hidden; border-radius: 22px; min-height: 600px; isolation: isolate; box-shadow: var(--mv-shadow-xl); display: flex; flex-direction: column; justify-content: space-between; padding: clamp(22px, 3.2vw, 40px); }
    .ai-scene > mv-mesh-gradient, .ai-scene > canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; }
    .ai-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
    .ai-chip { font: 600 12px/1 var(--mv-font-sans); letter-spacing: .02em; padding: 8px 12px; border-radius: 999px; background: oklch(0.16 0.02 270 / .55); color: oklch(0.98 0 0); border: 1px solid oklch(1 0 0 / .22); backdrop-filter: blur(10px); display: inline-flex; gap: 8px; align-items: center; }
    .ai-chip i { width: 7px; height: 7px; border-radius: 50%; background: oklch(0.72 0.2 25); box-shadow: 0 0 10px oklch(0.72 0.2 25); }
    .ai-toggle { font: 600 12px/1 var(--mv-font-sans); padding: 8px 12px; border-radius: 999px; cursor: pointer; border: 1px solid oklch(1 0 0 / .3); background: oklch(0.16 0.02 270 / .55); color: white; backdrop-filter: blur(10px); }
    .ai-toggle[aria-pressed="true"] { background: oklch(0.98 0 0); color: oklch(0.2 0.02 270); }
    .ai-toggle:focus-visible { outline: none; box-shadow: var(--mv-focus-ring); }
    .ai-demo .ai-title { display: block; margin: 0; font: 800 clamp(2.4rem, 5.2vw, 4.6rem)/0.98 var(--mv-font-sans); letter-spacing: -0.045em; }
    .ai-lede { margin: 18px 0 0; max-width: 34ch; font: 500 clamp(1rem, 1.35vw, 1.15rem)/1.45 var(--mv-font-sans); }
    .ai-meta { display: flex; flex-wrap: wrap; gap: 26px; margin-top: 26px; }
    .ai-meta mv-adaptive-ink { font: 700 1.6rem/1 var(--mv-font-sans); letter-spacing: -0.03em; }
    .ai-meta small { display: block; font: 500 .8rem/1.3 var(--mv-font-sans); letter-spacing: 0; margin-top: 6px; }
    .ai-actions { display: flex; gap: 10px; margin-top: 28px; flex-wrap: wrap; }
  </style>

  <!-- Scene 1 · WebGL mesh gradient -->
  <section class="ai-scene" id="ai-halden" aria-label="Studio Halden">
    <mv-mesh-gradient id="ai-mesh" colors="#0a0f26, #f7f1e6, #ff7b54, #1d2f7a" scale="0.85" speed="0.9"></mv-mesh-gradient>
    <div class="ai-top">
      <span class="ai-chip">Halden · web studio, Berlin</span>
      <button class="ai-toggle" type="button" aria-pressed="false" data-ai-debug>Measure contrast</button>
    </div>
    <div>
      <h2 class="ai-title"><mv-adaptive-ink source="#ai-mesh">Readable, whatever moves behind it.</mv-adaptive-ink></h2>
      <mv-adaptive-ink class="ai-lede" source="#ai-mesh" granularity="word">Every word measures its backdrop twenty times a second and picks its ink. Even on a shader that never sits still.</mv-adaptive-ink>
      <div class="ai-actions">
        <a class="mv-button" data-size="lg" data-shape="pill" href="#">See our work</a>
      </div>
    </div>
  </section>

  <!-- Scene 2 · video-like animated canvas -->
  <section class="ai-scene" aria-label="Nocturne E·7">
    <canvas id="ai-night" aria-hidden="true"></canvas>
    <div class="ai-top">
      <span class="ai-chip"><i></i> <span>Film · 00:<span data-ai-tc>12</span></span></span>
      <span class="ai-chip">Nocturne E·7</span>
    </div>
    <div>
      <h2 class="ai-title"><mv-adaptive-ink source="#ai-night">Night, in full light.</mv-adaptive-ink></h2>
      <div class="ai-meta">
        <div><mv-adaptive-ink source="#ai-night">380 mi<small>EPA range</small></mv-adaptive-ink></div>
        <div><mv-adaptive-ink source="#ai-night">18 min<small>10 to 80%</small></mv-adaptive-ink></div>
        <div><mv-adaptive-ink source="#ai-night">3.9 s<small>0-60 mph</small></mv-adaptive-ink></div>
      </div>
    </div>
  </section>

  <script type="module">
    const root = document.getElementById("ai-demo");
    const btn = root.querySelector("[data-ai-debug]");
    btn.addEventListener("click", () => {
      const on = btn.getAttribute("aria-pressed") !== "true";
      btn.setAttribute("aria-pressed", String(on));
      root.querySelectorAll("#ai-halden mv-adaptive-ink").forEach((el) => el.toggleAttribute("debug", on));
    });

    // "Night drive" footage: long-exposure light trails and a sodium glow sweeping across.
    const cv = root.querySelector("#ai-night");
    const ctx = cv.getContext("2d");
    const tc = root.querySelector("[data-ai-tc]");
    let seed = 7;
    const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
    const trails = Array.from({ length: 34 }, (_, i) => ({
      y: 0.12 + rnd() * 0.8, speed: 0.18 + rnd() * 0.5, len: 0.2 + rnd() * 0.5,
      off: rnd(), warm: i % 3 !== 0, w: 1.5 + rnd() * 5,
    }));
    const bokeh = Array.from({ length: 40 }, () => ({ x: rnd(), y: rnd() * 0.6, r: 6 + rnd() * 26, a: 0.05 + rnd() * 0.18, hue: rnd() < 0.5 ? 38 : 210 }));
    let W = 0, H = 0, T = 11.3;
    const size = () => { const r = cv.getBoundingClientRect(); const d = Math.min(devicePixelRatio || 1, 1.5); W = r.width; H = r.height; cv.width = Math.round(W * d); cv.height = Math.round(H * d); ctx.setTransform(d, 0, 0, d, 0, 0); draw(); };
    function draw() {
      const g = ctx.createLinearGradient(0, 0, 0, H);
      g.addColorStop(0, "#050817"); g.addColorStop(0.55, "#0c1430"); g.addColorStop(1, "#1a1020");
      ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
      ctx.globalCompositeOperation = "lighter";
      // the sweeping glow (tunnel exit / street lamp): what flips the ink
      const gx = W * (0.5 + 0.62 * Math.sin(T * 0.42)), gy = H * (0.62 + 0.12 * Math.sin(T * 0.27));
      const glow = ctx.createRadialGradient(gx, gy, 0, gx, gy, Math.max(W, H) * 0.55);
      glow.addColorStop(0, "rgba(255,244,220,1)"); glow.addColorStop(0.28, "rgba(255,196,120,.85)"); glow.addColorStop(0.62, "rgba(255,120,60,.18)"); glow.addColorStop(1, "rgba(0,0,0,0)");
      ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H);
      for (const b of bokeh) { ctx.fillStyle = `hsla(${b.hue} 90% 70% / ${b.a})`; ctx.beginPath(); ctx.arc(b.x * W + Math.sin(T * 0.2 + b.r) * 8, b.y * H, b.r, 0, 6.283); ctx.fill(); }
      for (const t of trails) {
        const x = (((t.off + T * t.speed * 0.25) % 1.6) - 0.3) * W, y = t.y * H, l = t.len * W;
        const lg = ctx.createLinearGradient(x - l, 0, x, 0);
        const c = t.warm ? "255,70,50" : "235,245,255";
        lg.addColorStop(0, `rgba(${c},0)`); lg.addColorStop(1, `rgba(${c},.9)`);
        ctx.fillStyle = lg; ctx.fillRect(x - l, y - t.w / 2, l, t.w);
      }
      tc.textContent = String(Math.floor(T) % 60).padStart(2, "0");
    }
    new ResizeObserver(size).observe(cv);
    const still = () => document.documentElement.dataset.motion === "reduce" || (document.documentElement.dataset.motion !== "full" && matchMedia("(prefers-reduced-motion: reduce)").matches);
    let visible = true, last = 0;
    new IntersectionObserver(([e]) => { visible = e.isIntersecting; }).observe(cv);
    const tick = (t) => {
      if (!cv.isConnected) return;
      const dt = last ? Math.min(0.1, (t - last) / 1000) : 0; last = t;
      if (visible && !document.hidden && !still()) { T += dt; draw(); }
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  </script>
</div>

API

Attributes

Name类型DefaultDescription
sourceselector | autoautoBackground to sample: <canvas> (2D or WebGL), <video>, <img>, or a shader element (<mv-silk>, <mv-mesh-gradient>…). “auto” picks the media among nearby ancestors that covers the text the most.
granularitygradient | word | chargradientgradient: continuous ink by columns inside the letters (background-clip); word / char: one solid color per word or per character (text-shadow as halo).
detailnumber (px)6Size of a sampling cell and width of an ink column.
ratenumber (Hz)20Samples per second (2 with reduced motion). Rendering itself stays smoothed on every frame.
smoothingnumber (ms)120Time constant of the ink crossfade (S-curve: the in-between gray only lasts a few frames).
targetnumber3 (large text) / 4.5Target WCAG ratio: below it, the compensating halo and weight kick in. By default, derived from the size: 3:1 above 24 px (or 18.66 px bold), 4.5:1 otherwise.
halonumber1Compensation strength (0 = off).
debugbooleanOverlays each tile with its measured ratio (AAA / AA / large text / fail) and a summary.
data-contrastread-onlyWorst average ratio measured (e.g. “6.84”), updated continuously; data-contrast-status = pass | fail.
data-moderead-onlypending (before the first sample) · ink · difference (fallback).

Properties

Name类型Description
report{ text, contrast }[]Latest measurement per tile.

Methods

NameDescription
refresh()Re-measures the words and resamples (after a layout change).

Events

NameDescription
mv-contrastWhen the status or the minimum changes (≤ 2.5/s). detail: { min, average, pass, target, tiles }.

CSS classes

NameDescription
mv-adaptive-ink-word / -charTiles generated around each word (or character); inline markup (<em>, <a>, <br>) is preserved.
mv-adaptive-ink-debug / -debug-tile / -debug-label / -debug-summaryMeasurement layer (aria-hidden).

CSS variables

NameDefaultDescription
--mv-adaptive-ink-lightoklch(0.99 0.004 90)Ink on dark backgrounds.
--mv-adaptive-ink-darkoklch(0.18 0.02 270)Ink on light backgrounds.

Accessibility

The text stays real text in the DOM (only <span>s are added around words): screen readers, selection and translation are untouched. WCAG contrast is the whole point: it is measured continuously (relative luminance, worst case under the glyphs) and compensated with a halo when no ink is enough. Reduced motion: 2 samples/s and ink switches without a crossfade. Forced colors: the system text color and backplate take over and the halo is dropped. Falls back to mix-blend-mode: difference when the background can’t be read (cross-origin media without CORS, no source).

本页面由 AI 翻译。报告翻译问题