Back to index

58 Ink bleed

Motion · Type in motion
Ink bleed

A still — with scripts on, it runs right here

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

motion-ink-bleed-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

1.2 KB
Ink bleed — Ink bleed text reveal

Make a blot of bled ink resolve into crisp letters. Define one SVG filter that passes feGaussianBlur (with an animated stdDeviation) through an feColorMatrix with values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9.5", which multiplies alpha by 22 and subtracts 9.5 to create a hard step. Apply filter: url(#…) to the heading and its subtitle and, from 10% to 70% of a 340vh pinned section, move stdDeviation from 9 down to 0 as 9 × (1 − e)^1.6, with e running 0 to 1. While the blur is large the ink swells into round blots, and as it shrinks the edges tighten into the letterforms. Once the blur reaches 0 remove the filter so the text renders normally and reads even without JavaScript. With reduced motion never apply it. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Remove the filter altogether once the blur reaches 0 so the text renders normally, reads without JavaScript and keeps its thin edges
- Step only the alpha channel and never write colour literals
- Share one filter between the heading and the subtitle
- Never apply the filter under reduced motion

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-ink-bleed/demo.html

Files

demo.html

8.9 KB

/designs/motion-ink-bleed/demo.html — One self-contained HTML file. CSS and plain JavaScript only; it fetches nothing.

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>58 滲み — Ink bleed text reveal</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

  --font: "Zen Kaku Gothic New", "Hiragino Sans", "Noto Sans JP", "Yu Gothic", system-ui, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --t-label: 13px;
  --t-body: 16px;
  --t-lead: 20px;
  --t-h2: 32px;
  --t-h1: clamp(40px, 7vw, 72px);

  --s1: 4px;
  --s2: 8px;
  --s3: 16px;
  --s4: 24px;
  --s5: 40px;
  --s6: 64px;
  --s7: 104px;

  --radius: 12px;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --dur: .7s;
  --measure: 1080px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.7;
  font-feature-settings: "palt";
  overflow-x: hidden;
}
h1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }
ul, ol { list-style: none; }
.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }
.dv-label {
  min-height: 72vh;
  display: grid;
  align-content: center;
  gap: var(--s3);
  padding-block: var(--s6);
  border-bottom: 1px solid var(--rule);
}
.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }
.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }
.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }
.dv-desc { font-size: var(--t-lead); color: var(--ink); }
.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }
.dv-stage { padding-block: var(--s7); }
.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }
.dv-lead { font-size: var(--t-lead); color: var(--muted); }
.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }
.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }
.dv-ph {
  border-radius: var(--radius);
  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
  aspect-ratio: 16 / 9;
}
.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }
.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }
.dv-end {
  min-height: 60vh;
  display: grid;
  place-items: center;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--t-label);
  letter-spacing: .12em;
}
/* dv-motion-base:end */

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.stack { display: grid; gap: var(--s6); }
.ib-zone { height: 340vh; }
.ib-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
@media (prefers-reduced-motion: reduce) {
  .ib-zone { height: auto; }
  .ib-stick { position: static; height: auto; padding-block: var(--s6); }
}
.ib-title { font-size: clamp(40px, 11vw, 140px); font-weight: 700; line-height: 1.2; text-align: center; }
.ib-sub { font-size: clamp(16px, 3vw, 32px); color: var(--muted); text-align: center; letter-spacing: .3em; }
/* 滲みは JS が動くあいだだけ。止まったあとは通常の描画に戻し、細い字の縁が痩せないようにする。 */
.js .ib-title.is-bleeding, .js .ib-sub.is-bleeding { filter: url(#ib-bleed); }
.ib-defs { position: absolute; width: 0; height: 0; }
</style>
</head>
<body data-motion-number="58">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">58</p>
    <h1 class="dv-name">滲み</h1>
    <p class="dv-ai">Ink bleed text reveal</p>
    <p class="dv-desc">にじんだ墨が輪郭を結んで文字になる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
<div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。スクロールすると、下の文字が、にじんだ墨の滲みから輪郭を結んでいきます。</p>
    </section>
  </div>

  <section class="ib-zone" data-progress="pin">
    <div class="ib-stick">
      <svg class="ib-defs" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
        <filter id="ib-bleed" x="-10%" y="-30%" width="120%" height="160%" color-interpolation-filters="sRGB">
          <feGaussianBlur id="ib-blur" in="SourceGraphic" stdDeviation="0"/>
          <feColorMatrix type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9.5"/>
        </filter>
      </svg>
      <h2 class="ib-title">墨がにじむ</h2>
      <p class="ib-sub">INK BLEED</p>
    </div>
  </section>

  <div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。戻すと、文字はまた滲みの中へ溶けます。</p>
    </section>
  </div>
</main>

<footer class="dv-end">END — 58 滲み</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
// ぼかしてから、透明度に段差をつけて切る(22 倍して 9.5 を引く)と、墨が紙に滲んだ縁になる。
// ぼかしを大きい値から 0 へ戻すと、滲みから輪郭が結ばれる。
const blur = document.getElementById('ib-blur');
const targets = document.querySelectorAll('.ib-title, .ib-sub');
function place(p) {
  const e = Math.min(1, Math.max(0, (p - 0.1) / 0.6));
  const sd = 9 * Math.pow(1 - e, 1.6);
  blur.setAttribute('stdDeviation', sd.toFixed(2));
  // ぼかしが 0 になったら、フィルターごと外す。
  targets.forEach((t) => t.classList.toggle('is-bleeding', e < 1));
}
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

/ai/assets/motion-ink-bleed.json