Exclusifbêta

Adaptive Ink <mv-adaptive-ink>

Texte qui reste lisible sur n’importe quel fond animé (shader WebGL, vidéo, canvas, image) : l’arrière-plan est échantillonné en temps réel sous chaque mot, découpé en colonnes, et chaque colonne choisit une encre claire ou foncée avec un fondu enchaîné : une même lettre peut être à moitié blanche, à moitié noire. Un halo et une graisse renforcée sauvent les zones grises ; le contraste WCAG mesuré est exposé et peut être visualisé.

CatégorieTexte animé
TypeWeb Component (<mv-adaptive-ink>)
Statutbêta
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

Installation

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

Agent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["adaptive-ink"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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-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).

Cette page a été traduite par IA. Signaler un problème de traduction