エクスクルーシブ

Adaptive Ink <mv-adaptive-ink>

どんなアニメーション背景(WebGL シェーダー、動画、canvas、画像)の上でも読みやすさを保つテキストです。各単語の下の背景をリアルタイムでサンプリングして列に分割し、列ごとに明るいインクか暗いインクをクロスフェードで選びます。1 文字の半分が白、半分が黒になることもあります。

仕組み

どんなアニメーション背景(WebGL シェーダー、動画、canvas、画像)の上でも読みやすさを保つテキスト:単語ごとに下の背景をリアルタイムでサンプリングして縦の列に分割し、列ごとにクロスフェードで明るいインクか暗いインクを選びます。1文字が半分白、半分黒になることもあります。グレーの領域ではハローと太さの追加で読みやすさを補います。測定した 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 で翻訳されています。翻訳の問題を報告