익스클루시브

Adaptive Ink <mv-adaptive-ink>

어떤 애니메이션 배경(WebGL 셰이더, 비디오, canvas, 이미지) 위에서도 읽기 쉬운 텍스트입니다. 단어마다 그 아래의 배경을 실시간으로 샘플링해 열 단위로 나누고, 각 열이 크로스페이드로 밝은 잉크나 어두운 잉크를 고릅니다. 글자 하나가 절반은 흰색, 절반은 검은색이 될 수도 있습니다.

작동 방식

어떤 애니메이션 배경(WebGL 셰이더, 비디오, 캔버스, 이미지) 위에서도 읽기 쉬운 텍스트: 모든 단어 아래의 배경을 실시간으로 샘플링해 열로 나누고, 각 열이 크로스페이드로 밝은 잉크나 어두운 잉크를 선택합니다. 글자 하나가 절반은 흰색, 절반은 검은색이 될 수도 있습니다. 회색 영역은 후광과 추가 굵기로 보완하며, 측정된 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로 번역되었습니다. 번역 문제 신고