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.
| Category | Animated text |
|---|---|
| Type | Web Component (<mv-adaptive-ink>) |
| Status | beta |
| Keywords | exclusive, 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
Install
node scripts/add.mjs adaptive-ink --out ./src/marvelousAI 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
| Name | Type | Default | Description |
|---|---|---|---|
source | selector | auto | auto | Background 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. |
granularity | gradient | word | char | gradient | gradient: continuous ink by columns inside the letters (background-clip); word / char: one solid color per word or per character (text-shadow as halo). |
detail | number (px) | 6 | Size of a sampling cell and width of an ink column. |
rate | number (Hz) | 20 | Samples per second (2 with reduced motion). Rendering itself stays smoothed on every frame. |
smoothing | number (ms) | 120 | Time constant of the ink crossfade (S-curve: the in-between gray only lasts a few frames). |
target | number | 3 (large text) / 4.5 | Target 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. |
halo | number | 1 | Compensation strength (0 = off). |
debug | boolean | Overlays each tile with its measured ratio (AAA / AA / large text / fail) and a summary. | |
data-contrast | read-only | Worst average ratio measured (e.g. “6.84”), updated continuously; data-contrast-status = pass | fail. | |
data-mode | read-only | pending (before the first sample) · ink · difference (fallback). |
Properties
| Name | Type | Description |
|---|---|---|
report | { text, contrast }[] | Latest measurement per tile. |
Methods
| Name | Description |
|---|---|
refresh() | Re-measures the words and resamples (after a layout change). |
Events
| Name | Description |
|---|---|
mv-contrast | When the status or the minimum changes (≤ 2.5/s). detail: { min, average, pass, target, tiles }. |
CSS classes
| Name | Description |
|---|---|
mv-adaptive-ink-word / -char | Tiles generated around each word (or character); inline markup (<em>, <a>, <br>) is preserved. |
mv-adaptive-ink-debug / -debug-tile / -debug-label / -debug-summary | Measurement layer (aria-hidden). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-adaptive-ink-light | oklch(0.99 0.004 90) | Ink on dark backgrounds. |
--mv-adaptive-ink-dark | oklch(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).