Exclusivebeta

Adaptive Ink — <mv-adaptive-ink>

Text that stays readable on any animated background (WebGL shader, video, canvas, image): the backdrop is sampled in real time under every word, sliced into columns, and each column picks a light or dark ink with a crossfade — a single letter can be half white, half black. A halo and extra weight rescue gray areas; the measured WCAG contrast is exposed and can be visualized.

CategoryAnimated text
TypeWeb Component (<mv-adaptive-ink>)
Statusbeta
Keywordsexclusive, contrast, wcag, readability, text, video, shader, luminance, adaptive, a11y

When to use

Avoid when

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["adaptive-ink"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): 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.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<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-lumen" aria-label="Studio Lumen">
    <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">Lumen · 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> Film · 00:<span data-ai-tc>12</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-lumen 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

NameTypeDefaultDescription
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

NameTypeDescription
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. Falls back to mix-blend-mode: difference when the background can’t be read (cross-origin media without CORS, no source).