Back to index

34 Iris mask

Motion · Imagery and depth
Iris mask

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-iris-mask-everything.md

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

Prompt

1.1 KB
Iris mask — Circular iris mask expand

Build an effect that pins the screen and opens the next screen as a circle from one point at the centre, laid over the current screen. The outer section is 400vh tall and holds a 100vh box with position: sticky; top: 0. Inside the box, place the next screen over the current one (the lower face), and clip only the upper face with clip-path: circle(calc(var(--p) * 75vmax) at 50% 50%). Compute the progress p (0 to 1) as p=0 when the section's top reaches the top of the screen and p=1 when its bottom reaches the bottom. Put a 12px dot at the centre to mark where the circle starts, and keep both faces inside the box at 375px width so nothing scrolls sideways. Implement it with plain CSS and JavaScript only, with no library.

Do not drop:
- Clip only the upper face with the circle; the face beneath stays still
- Derive the circle radius from scroll progress p, never from a timer
- Under prefers-reduced-motion, drop the circle and show only the next screen

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

Files

demo.html

8.2 KB

/designs/motion-iris-mask/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>34 アイリスマスク — Circular iris mask expand</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() のみ ---- */
.iris-zone { height: 400vh; --p: 0; }
.iris-box { position: sticky; top: 0; height: 100vh; display: grid; overflow: hidden; }
.iris-face { grid-area: 1 / 1; min-height: 0; display: grid; align-content: center; padding-block: var(--s6); }
.iris-face .dv-wrap { display: grid; gap: var(--s3); }
.iris-face--now { background: var(--surface); }
.iris-face--next {
  background: var(--ink);
  color: var(--ground);
  clip-path: circle(calc(var(--p) * 75vmax) at 50% 50%);
}
.iris-face--next .dv-eyebrow { color: var(--accent-soft); }
.iris-face--next .dv-lead { color: var(--rule); }
.iris-dot {
  grid-area: 1 / 1;
  place-self: center;
  position: relative;
  z-index: 1;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 4px var(--ground);
}

@media (prefers-reduced-motion: reduce) {
  .iris-zone { height: auto; }
  .iris-box { position: static; height: auto; }
  .iris-face { padding-block: var(--s7); }
  .iris-face--now, .iris-dot { display: none; }
  .iris-face--next { clip-path: none; }
}
</style>
</head>
<body data-motion-number="34">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">34</p>
    <h1 class="dv-name">アイリスマスク</h1>
    <p class="dv-ai">Circular iris mask expand</p>
    <p class="dv-desc">円形に開いて次の画面が出る</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="iris-zone" data-progress="pin">
    <div class="iris-box">
      <div class="iris-face iris-face--now">
        <div class="dv-wrap">
          <p class="dv-eyebrow">Current</p>
          <h2 class="dv-h2">見出しが入ります</h2>
          <p class="dv-lead">本文のダミーです。中心の点から円が広がります。</p>
          <div class="dv-ph dv-ph--alt"></div>
        </div>
      </div>
      <div class="iris-face iris-face--next">
        <div class="dv-wrap">
          <p class="dv-eyebrow">Next</p>
          <h2 class="dv-h2">次の画面の見出し</h2>
          <p class="dv-lead">本文のダミーです。開き切ると、次の画面だけが残ります。</p>
          <div class="dv-ph dv-ph--ink"></div>
        </div>
      </div>
      <span class="iris-dot" aria-hidden="true"></span>
    </div>
  </section>
</main>

<footer class="dv-end">END — 34 アイリスマスク</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 ---- */
dvProgress();
</script>
</body>
</html>

/ai/assets/motion-iris-mask.json