Design Vault 用途・構成・配色・本文で検索 /
索引 カテゴリ タグ English

一式

文字の演出 45–60 — 一画から粒子まで、文字そのものを動かす

筆順・粒子・波・曲線・縦書きなど、文字を書く・砕く・集める動きと、一部を伸ばす・消す・星を散らすといったタイトル文字の飾り計 16 種。見出しの印象をスクロールで強める

16 枚

持ち帰る JSON 何枚あっても 1 回で。順に束ねて写すので、貼った先でも並びが残る。 仕様の無い枠は、題名と JSON の場所だけを書く

読む順

  1. 01 動き 筆順 筆順
  2. 02 動き 粒子ばらけ 粒子ばらけ
  3. 03 動き 粒子集結 粒子集結
  4. 04 動き 文字の波 文字の波
  5. 05 動き 曲線に沿う文字 曲線に沿う文字
  6. 06 動き 縦書きの筆記 縦書きの筆記
  7. 07 動き 文字の集合 文字の集合
  8. 08 動き 一部を伸ばす 一部を伸ばす
  9. 09 動き グラデ文字 グラデ文字
  10. 10 動き 一部を消す 一部を消す
  11. 11 動き 装飾を散らす 装飾を散らす
  12. 12 動き 斜線を入れる 斜線を入れる
  13. 13 動き 一文字だけ大きく 一文字だけ大きく
  14. 14 動き 滲み 滲み
  15. 15 動き 下に英語 下に英語
  16. 16 動き 文字を繋げる 文字を繋げる

この一式について

持ち帰る

コピーして貼るか、ファイルに保存して渡す。ネットにつながらない環境には「全部入り」を 1 つ渡せば足りる。

全部入り

scroll-motion-lettering-everything.md

このパネルの中身を、URL の先にあるファイルの中身まで含めて 1 つにまとめた Markdown

依頼文

24.2 KB
# 筆順
筆順 — Stroke-order handwriting

「日」(4 画)と「本」(5 画)の 2 文字を、スクロールに合わせて 1 画ずつ筆順どおりに書いてください。各画は SVG の path(stroke-linecap: round、stroke-width 7)で、pathLength="1" を付け、stroke-dasharray: 1 のまま stroke-dashoffset を 1 から 0 へ動かして描きます。420vh の pin 区画の進み 8% から 86% を使い、画ごとの実際の長さに比例して進みを配ります(長い画ほど長く書く)。いま書いている画の先端にはアクセント色の点を置き、下に「筆順 05 / 09」と画数を出します。各文字には薄い手本の線と点線の十字(米字格の半分)を敷き、JavaScript が動かない、または動きを減らす設定では 9 画を書き終えた状態で見せます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 各画の path は筆の入りの点から書き始める。逆の端から描くと、筆順の向きが壊れる
- 画の順は筆順そのもの。1 画の描き終わりを待ってから次の画に移る
- 長さ 0 の線も丸い端の点が残るので、書き始める前の画は visibility: hidden にする
- 動きを減らす設定では、全画を書き終えた状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-stroke-order/demo.html

---

# 粒子ばらけ
粒子ばらけ — Text disintegration into particles

一行の見出しを、スクロールに合わせて粒に砕いて風に散らしてください。見出しを画面外の canvas に太字で描いて画素を拾い、2px 刻み(粒が 9,000 を超えるなら刻みを広げる)で粒の位置を決めます。400vh の pin 区画で、進み 12% までは文字のまま、そこから 90% までに左の粒から順に風が当たります(粒の遅れは x 座標 × 0.45)。1 粒ごとに、右上へ流れる量(canvas 幅の 34〜61%)、向きのばらつき、上昇量を持たせ、位置は進みだけで決まる関数にします。粒は easeIn で加速しながら透明になり、大きさも 40% まで縮みます。粒の約 1 割はアクセント色にします。乱数は固定の種から作る疑似乱数を使い、スクロールを戻すと粒が同じ道を帰って文字に戻ります。本物の文は読み上げ用に視覚的に隠して残し、動きを減らす設定では文字のまま止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 粒の位置は進みだけで決まる関数にする。時間で積分すると、戻したときに文字へ戻らない
- 乱数は固定の種から作り、毎回同じ粒の並びにする
- 書体が読み込まれてから画素を拾う。先に拾うと別の書体の形を粒にする
- 本物の文は読み上げ用に残し、動きを減らす設定では文字のまま止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-text-shatter/demo.html

---

# 粒子集結
粒子集結 — Particles assemble into text

散らばった粒が渦を巻いて集まり、一行の見出しになる動きを作ってください。見出しを画面外の canvas に太字で描いて画素を拾い、2px 刻み(粒が 9,000 を超えるなら刻みを広げる)で「行き先」を決めます。出発点は画面の中心のまわりの大きな輪(幅の 27〜65%)の上に、粒ごとにばらまきます。400vh の pin 区画で、進み 6% から 70% までに集め、あとは文字のまま残します。1 粒ごとに 0〜0.4 の出発の遅れと渦の向き・強さを持たせ、出発点と行き先を結ぶ線から横へ最大 90px ふくらむ弧を easeOut(1 − (1 − t)³)でたどらせます(着く手前でふくらみは 0 に戻る)。粒は小さな円で、着くにつれて半径が大きくなり隣と重なって文字の面になります。位置は進みだけで決まる関数にして、戻すと文字がまた粒にほどけます。本物の文は読み上げ用に視覚的に隠して残し、動きを減らす設定では集まった文字で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 粒の位置は進みだけで決まる関数にする。戻したときに同じ道で散らばる
- 出発の遅れと渦の向きは粒ごとに固定の乱数で決める。毎回同じ集まり方にする
- 着いた粒は半径を広げて隣と重ね、文字が面として読めるようにする
- 本物の文は読み上げ用に残し、動きを減らす設定では集まった文字で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-text-gather/demo.html

---

# 文字の波
文字の波 — Character wave on scroll

一行の見出しと、その下の小さな英文を、スクロールに合わせて波が通り抜ける動きを作ってください。文を 1 字ずつ inline-block の要素に割り、360vh の pin 区画の進み p から、字 i の縦のずれを translateY(−sin(p × 2 × 2π − i/n × 2π × 1.1) × 0.32em × sin(πp)) で決めます(波は 2 回通り抜け、進みの両端では振れ幅 0 に絞る)。波の山にあたる字は 1.18 倍まで拡大し、sin が 0.8 を超えた字だけアクセント色にします。下の英文は逆向きの波にします。折り返さず、画面幅に合わせて文字の大きさを clamp で決めます。本物の文は aria-label に残し、動きを減らす設定では一直線のまま止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 進みの両端では振れ幅を 0 に絞り、止まっているときは文字が一直線に揃う
- 字の位置は進みだけで決まる関数にする。戻せば波も戻る
- 行を折り返さない。375px でも clamp で収める
- 本物の文は aria-label に残し、割った字は読み上げから外す

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-char-wave/demo.html

---

# 曲線に沿う文字
曲線に沿う文字 — Text on a path

曲がりくねった道に沿って、一文が流れていく動きを作ってください。SVG(viewBox 1200 × 560)に、画面の左右をはみ出す S 字の path を点線で引き、その上に textPath で太字の一文(64px)を乗せます。360vh の pin 区画の進み p に合わせて startOffset を「道の全長 − p × (道の全長 + 文の長さ)」にし、文の頭が道の終点に現れてから、文の最後が始点を抜けるまで流します。文中の一語だけアクセント色にします。文の長さは書体で変わるので、書体が読み込まれてから getComputedTextLength で測ります。本物の文は SVG の aria-label に残し、動きを減らす設定では文全体が道に乗った位置で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- startOffset は進みだけで決める。文の頭が終点に来た状態が p = 0
- 文の長さは書体が読み込まれてから測る。先に測るとずれる
- 道は画面の両端をはみ出させ、文が途中で切れて見えないようにする
- 本物の文は aria-label に残し、動きを減らす設定では全体が道に乗った位置で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-text-on-path/demo.html

---

# 縦書きの筆記
縦書きの筆記 — Vertical writing reveal

縦書きの短い詩を、原稿用紙の上へ一字ずつ書き込む動きを作ってください。writing-mode: vertical-rl の箱に 3 行(右の行から読む)を置き、1 行は 9 マスぶんの高さにします。マスの大きさは clamp(30px, 6.2vw, 56px) で、罫線は linear-gradient を 2 本重ねて同じ長さごとに引き、字は枠の 72% の大きさにします。380vh の pin 区画の進み 0〜85% で、右の行の上から下、次は左の行へと、字の順に 1 字ずつ現れます。現れる字は上から 0.35em ずれ、ぼかし 4px・透明からくっきり戻り(0.5 秒)、いま書いた最後の字だけアクセント色にします。下に「17 / 25」のような書いた字数を出します。本物の文は各行の aria-label に残し、動きを減らす設定では全字を書き終えた状態で見せます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- flex の向きに注意。縦書きの箱では row が上から下になるので、行を右から左へ並べるときは block のまま積む
- 罫線のマスと字の送りを同じ長さ(--cell)にそろえ、箱に余白(padding)を付けない
- 字は先頭から数えた順に現れ、戻せば後ろから消える
- 本物の文は aria-label に残し、動きを減らす設定では全字を表示する

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-vertical-write/demo.html

---

# 文字の集合
文字の集合 — Scattered letters snap into place

四方に散らばった文字が、回りながら戻って一列に整列する動きを作ってください。日本語の見出しと小さな英文を 1 字ずつ inline-block に割り、字ごとに固定の乱数で散った姿(横 ±42vw、縦 ±42vh、回転 ±260°、Y 軸の回転 ±120°、拡大率 0.5〜2.7)と戻り始めの遅れ(0〜0.35)を決めます。360vh の pin 区画の進み p から、字ごとの時間 t = clamp((p / 0.8 − 遅れ) / 0.65) を出し、easeOut(1 − (1 − t)⁴)で散った姿から整列した姿へ補間します。各字の transform の先頭に perspective(900px) を付けて奥行きを出し、不透明度は 0.15 から t に応じて 1 まで上げ、散っている字はアクセント色、整列し終えた字だけ本来の色にします。横へはみ出した字で横スクロールが出ないよう、舞台は overflow: clip にします。本物の文は aria-label に残し、動きを減らす設定では整列した状態で見せます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 散った姿は固定の乱数で決め、戻したときに同じ位置へ散る
- 舞台は overflow: clip にして、はみ出した字で横スクロールを出さない
- 整列し終えた字は transform を 0 にそろえ、1 文字もずれたまま残さない
- 本物の文は aria-label に残し、動きを減らす設定では整列した状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-letters-scatter/demo.html

---

# 一部を伸ばす
一部を伸ばす — Stretch one stroke of a title

見出し「一部を伸ばす」のうち、「一」の画だけを長く伸ばす動きを作ってください。「一」は文字ではなく、高さ 0.09em・幅 1em のアクセント色の棒として作り、右端を残りの文字(「部を伸ばす」)の左に付けたまま、transform-origin: right center の scaleX で左へ伸ばします。320vh の pin 区画の進み 10% から 80% で、倍率を 1 から「棒の右端が画面の左端に届く倍率」まで 線形(緩急なし)に動かします(届く倍率は棒の右端の位置 ÷ 1em で測り、リサイズで測り直す)。幅は動かさず scaleX だけを使うので、文字の位置は動きません。下に小さく「STRETCH ONE STROKE」と添えます。本物の文は aria-label に残し、動きを減らす設定では 6 倍に伸ばした状態で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 棒は幅を動かさず scaleX だけで伸ばす。文字の位置を動かさない
- 伸ばす基点は棒の右端。左へ伸びて、残りの文字には触れない
- 届く倍率は画面幅から測り、リサイズで測り直す。375px でも画面外へはみ出さない
- 舞台は overflow: clip にして、横スクロールを出さない

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-stretch-stroke/demo.html

---

# グラデ文字
グラデ文字 — Gradient text sweep

大きな見出し 3 行の、文字の中で色の帯が流れる動きを作ってください。見出しに background-clip: text と -webkit-text-fill-color: transparent を使い、背景は 100deg のグラデーション(インク色 → アクセント → 明るいアクセント → アクセント → インク色)を幅 280% で敷きます。各行を data-progress="through" にして、帯の位置を background-position の横位置で動かします(1 行目と 3 行目は右へ、2 行目は左へ、進み 0 → 1 で 0% から 100%)。色は var() と color-mix() だけで作り、文字の外へは色がはみ出しません。動きを減らす設定では中央の位置で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 帯の位置は進みだけで決まる。background-position を CSS の --p から作る
- 文字の外に色を出さない。background-clip: text と透明な塗りを必ず対にする
- 色は変数と color-mix() だけで作り、リテラルを書かない
- 動きを減らす設定では中央の位置で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-gradient-sweep/demo.html

---

# 一部を消す
一部を消す — Partially erased letters

見出しの文字が、下半分から 1 字ずつ消えていく動きを作ってください。見出しを 1 字ずつ要素に割り、各字の本体に clip-path: inset(0 0 calc(var(--e) * 0.62em) 0) を当てて、下から 0.62em(字のおよそ半分)まで消します。--e は 0 から 1 で、字 i の開始は 10% + i/n × 45% の進み、消し切るまでに 30% の進みを使います(左の字から時間差で)。消した端にはアクセント色の 2px の線を置き、transform: translateY(calc(var(--e) * -0.62em)) で切り口に沿わせます。340vh の pin 区画で、字の間隔は 0.18em あけます。本物の文は aria-label に残し、動きを減らす設定では --e を 0.7 にして止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 消すのは clip-path だけ。文字の色を地色に変えて隠さない(地色が変わると見える)
- 切り口の線は transform で動かし、top を書き換えない
- 字ごとの --e は進みだけで決まる。戻せば元どおり現れる
- 本物の文は aria-label に残し、動きを減らす設定では --e を 0.7 で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-erase-part/demo.html

---

# 装飾を散らす
装飾を散らす — Sparkle ornaments around a title

見出しのまわりに、四角い星(四芒星)の飾りを順に散らす動きを作ってください。星は SVG の path(M0 -10 C1 -3 3 -1 10 0 C3 1 1 3 0 10 C-1 3 -3 1 -10 0 C-3 -1 -1 -3 0 -10Z)で、見出しの箱の外側の 12 か所に、大きさ 16〜40px で置きます(色はアクセントとインクを交互に)。星 k の出番は、340vh の pin 区画の進みを 0.9 で割った値が 0.08 から 0.88 まで順に並べた値を超えたときで、そこから 0.14 のあいだに scale を 0 から easeOutBack(1 + 2.7(t−1)³ + 1.7(t−1)²)で 1 まで、不透明度を 0 から 1 まで上げます。現れるときに 90° 回り、そのあともスクロールに合わせて少しずつ回ります。星は文字に重ねません。本物の見出しは通常の文字で残し、飾りは aria-hidden にします。動きを減らす設定では全部の星を出した状態で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 星は見出しの外側にだけ置き、文字に重ねない
- 出番・大きさ・回転は進みだけで決まる。戻せば星も順に消える
- 飾りは aria-hidden にし、見出しは本物の文字のまま残す
- 動きを減らす設定では全部の星を出した状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-sparkle-scatter/demo.html

---

# 斜線を入れる
斜線を入れる — Diagonal slashes through a title

見出しの文字の間に、斜線を左から順に引いていく動きを作ってください。見出しは字間 0.2em の太字で、上に重ねた SVG(viewBox は見出しの実寸 px に合わせ、リサイズで取り直す)に、右上から左下へ向かう斜線を 7 本、見出しの幅いっぱいに等間隔で置きます。線は stroke を var(--accent)、幅 3px、pathLength="1" で、340vh の pin 区画の進み 10% から、1 本ごとに 10% ずつ遅らせ、各線を 14% の進みで上から下へ stroke-dashoffset 1 → 0 で引きます。引き始める前の線は visibility: hidden にします。引いた線は残し、戻すと逆順に消えます。本物の見出しは aria-label に残し、動きを減らす設定では 7 本とも引いた状態で見せます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 線は上から下へ、左の線から順に引く。次の線は前の線が引き終わる少し前から始めてよい
- SVG の座標は見出しの実寸(px)にそろえる。引き伸ばした座標だと、線の太さと破線の長さが歪む
- 引く前の線は visibility: hidden。長さ 0 でも丸い端の点が残る
- 本物の見出しは aria-label に残し、動きを減らす設定では全線を引いた状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-slash-strike/demo.html

---

# 一文字だけ大きく
一文字だけ大きく — One oversized letter in a title

見出し「ひと文字だけ大きく」のうち「大」の一字だけを大きく育てる動きを作ってください。見出しを 1 字ずつ inline-block に割り、「大」だけアクセント色にして、transform-origin を 50% 100% に置いた scale で 1 から 2.4 倍まで easeInOut(t²(3 − 2t))で育てます。幅は動かさず、左側の字は translateX(−(2.4 − 1) ÷ 2 × e em)、右側の字は translateX(+同じ量) で左右へ道を空けます(e は 0 から 1 の進み具合)。340vh の pin 区画の進み 12% から 72% を使い、字の下端を揃えるので大きくなった字は上へ伸びます。上へ伸びる分、見出しの上に 1.4em の余白を取ります。本物の見出しは aria-label に残し、動きを減らす設定では 2.4 倍に育った状態で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 幅は動かさない。まわりの字は translateX だけで左右へ退く
- 拡大の基点は字の下端中央。ほかの字と下端をそろえる
- 大きくなる字の分、上に余白を取って、前の区画や文字に重ねない
- 本物の見出しは aria-label に残し、動きを減らす設定では育った状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-one-letter-big/demo.html

---

# 滲み
滲み — Ink bleed text reveal

にじんだ墨の塊が輪郭を結んで文字になる動きを作ってください。SVG の filter を 1 本定義し、feGaussianBlur(stdDeviation を動かす)の出力を feColorMatrix(values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9.5"、透明度を 22 倍して 9.5 を引いて段差をつける)に通します。見出しと副題の CSS に filter: url(#…) を当て、340vh の pin 区画の進み 10% から 70% で、stdDeviation を 9 × (1 − e)^1.6(e は 0 から 1)で 9 から 0 へ戻します。ぼかしが大きいあいだは墨の塊が丸く滲み、小さくなるにつれて縁が文字の形に締まります。ぼかしが 0 になったらフィルターを外して通常の描画に戻すので、JavaScript が動かなくても読めます。動きを減らす設定ではフィルターを当てません。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- ぼかしが 0 になったらフィルターごと外し、通常の描画に戻す(JS が動かなくても文字が読め、細い字の縁も痩せない)
- 色は変えず、透明度だけに段差をつける(色のリテラルを書かない)
- フィルターは 1 本を見出しと副題で共有する
- 動きを減らす設定ではフィルターを当てない

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-ink-bleed/demo.html

---

# 下に英語
下に英語 — English subtitle under a title

日本語の大きな見出しの下に、線を引き、英語の副題を現す動きを作ってください。見出しの直下に見出しと同じ幅の 3px の線を置いて、transform: scaleX(0 → 1)(左端が基点)で引きます。その下の英語はイタリックの明朝(Georgia)で、字間を 0.6em から 0.14em へ詰めながら、不透明度を 0 から 1 にします。340vh の pin 区画で、線は進み 12% から 42%、英語は 30% から 75% で、どちらも easeOut(1 − (1 − t)³)です。英語は見出しの意味を補うだけにして、見出しより小さく、薄い色(var(--muted))にします。折り返さず、375px でも収まるよう clamp で大きさを決めます。動きを減らす設定では線を引き終え、英語が現れた状態で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 線は scaleX で引く。幅を動かさない
- 英語は見出しより小さく薄くして、主役にしない
- 折り返さない。375px でも clamp で収める
- 動きを減らす設定では線と英語が出た状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-english-underline/demo.html

---

# 文字を繋げる
文字を繋げる — Connected letters

見出しの文字と文字を、一本の線でつなぐ動きを作ってください。SVG(viewBox 1200 × 300)に太字の見出し(150px)を中央寄せで置き、各字の外接の中心を getExtentOfChar で拾います。字の高さの 62% と 32% を交互に通る点をつくり、前後に 90px ずつ延ばした両端を加えて、水平な接線の 3 次ベジェでなめらかにつなぎます。この線は文字の後ろに、幅 26px・var(--accent-mid) の丸い端で描き、pathLength="1" を使って 340vh の pin 区画の進み 10% から 80% で、stroke-dashoffset を 1 → 0 へ easeOut(1 − (1 − t)²)で引きます。書体が読み込まれてから字の位置を測ります。本物の見出しは SVG の aria-label に残し、動きを減らす設定では線を引き終えた状態で見せます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 線は文字の後ろに描く。文字の可読性を落とさない
- 字の位置は書体が読み込まれてから測る
- 線は 1 本の path。字ごとに別の線にしない
- 本物の見出しは aria-label に残し、動きを減らす設定では描き終えた状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-linked-letters/demo.html

仕様

1.4 KB
# 筆順
https://design-vault.pages.dev/ai/assets/motion-stroke-order.json

---

# 粒子ばらけ
https://design-vault.pages.dev/ai/assets/motion-text-shatter.json

---

# 粒子集結
https://design-vault.pages.dev/ai/assets/motion-text-gather.json

---

# 文字の波
https://design-vault.pages.dev/ai/assets/motion-char-wave.json

---

# 曲線に沿う文字
https://design-vault.pages.dev/ai/assets/motion-text-on-path.json

---

# 縦書きの筆記
https://design-vault.pages.dev/ai/assets/motion-vertical-write.json

---

# 文字の集合
https://design-vault.pages.dev/ai/assets/motion-letters-scatter.json

---

# 一部を伸ばす
https://design-vault.pages.dev/ai/assets/motion-stretch-stroke.json

---

# グラデ文字
https://design-vault.pages.dev/ai/assets/motion-gradient-sweep.json

---

# 一部を消す
https://design-vault.pages.dev/ai/assets/motion-erase-part.json

---

# 装飾を散らす
https://design-vault.pages.dev/ai/assets/motion-sparkle-scatter.json

---

# 斜線を入れる
https://design-vault.pages.dev/ai/assets/motion-slash-strike.json

---

# 一文字だけ大きく
https://design-vault.pages.dev/ai/assets/motion-one-letter-big.json

---

# 滲み
https://design-vault.pages.dev/ai/assets/motion-ink-bleed.json

---

# 下に英語
https://design-vault.pages.dev/ai/assets/motion-english-underline.json

---

# 文字を繋げる
https://design-vault.pages.dev/ai/assets/motion-linked-letters.json

ファイル

01 motion-stroke-order/demo.html

11.6 KB

/designs/motion-stroke-order/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>45 筆順 — Stroke-order handwriting</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); }
.so-zone { height: 420vh; }
.so-stick {
  position: sticky; top: 0; height: 100vh;
  display: grid; place-content: center; justify-items: center; gap: var(--s4);
}
/* 幅は画面から決める。中央寄せの grid の中で % を使うと箱が点まで縮む。 */
.so-board { display: block; width: min(100vw - 2 * var(--s4), 760px); height: auto; overflow: visible; }
.so-guide { fill: none; stroke: color-mix(in srgb, var(--ink) 11%, var(--ground)); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
.so-ink { fill: none; stroke: var(--ink); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
.so-grid { fill: none; stroke: var(--rule); stroke-width: 1; stroke-dasharray: 4 5; }
.so-frame { fill: none; stroke: var(--rule); stroke-width: 1.5; }
.so-tip { fill: var(--accent); opacity: 0; }
.so-count { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }
.so-count b { color: var(--accent); font-weight: 700; }

@media (prefers-reduced-motion: reduce) {
  .so-zone { height: auto; }
  .so-stick { position: static; height: auto; padding-block: var(--s6); }
  .so-tip { display: none; }
}
</style>
</head>
<body data-motion-number="45">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">45</p>
    <h1 class="dv-name">筆順</h1>
    <p class="dv-ai">Stroke-order handwriting</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="so-zone" data-progress="pin">
    <div class="so-stick">
      <svg class="so-board" viewBox="0 0 240 120" role="img" aria-label="日本(にほん)" xmlns="http://www.w3.org/2000/svg">
        <g class="so-frames">
          <rect class="so-frame" x="2" y="10" width="104" height="104" rx="4"/>
          <rect class="so-frame" x="134" y="10" width="104" height="104" rx="4"/>
          <path class="so-grid" d="M54 10V114M2 62H106M186 10V114M134 62H238"/>
        </g>
        <!-- 日(4 画)。各画の始点が筆の入り -->
        <g class="so-char" transform="translate(4 12)">
          <path class="so-guide" d="M28 16V84"/><path class="so-guide" d="M28 16H72V84"/><path class="so-guide" d="M28 50H72"/><path class="so-guide" d="M28 84H72"/>
        </g>
        <!-- 本(5 画) -->
        <g class="so-char" transform="translate(136 12)">
          <path class="so-guide" d="M12 34H88"/><path class="so-guide" d="M50 10V92"/><path class="so-guide" d="M47 42Q36 64 12 80"/><path class="so-guide" d="M53 42Q64 64 88 80"/><path class="so-guide" d="M32 73H68"/>
        </g>
        <g class="so-draw" transform="translate(4 12)">
          <path class="so-ink" pathLength="1" d="M28 16V84"/><path class="so-ink" pathLength="1" d="M28 16H72V84"/><path class="so-ink" pathLength="1" d="M28 50H72"/><path class="so-ink" pathLength="1" d="M28 84H72"/>
        </g>
        <g class="so-draw" transform="translate(136 12)">
          <path class="so-ink" pathLength="1" d="M12 34H88"/><path class="so-ink" pathLength="1" d="M50 10V92"/><path class="so-ink" pathLength="1" d="M47 42Q36 64 12 80"/><path class="so-ink" pathLength="1" d="M53 42Q64 64 88 80"/><path class="so-ink" pathLength="1" d="M32 73H68"/>
        </g>
        <circle class="so-tip" r="4.5" cx="0" cy="0"/>
      </svg>
      <p class="so-count">筆順 <b>09</b> / 09</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 — 45 筆順</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 ---- */
const board = document.querySelector('.so-board');
const tip = board.querySelector('.so-tip');
const countNo = document.querySelector('.so-count b');
const inks = [...board.querySelectorAll('.so-ink')];
const lens = inks.map((p) => p.getTotalLength());
// 画ごとの長さに比例して進みを配る。長い画ほど、書くのに長くかかる。
const total = lens.reduce((a, b) => a + b, 0);
const ends = [];
lens.reduce((acc, len, i) => (ends[i] = (acc + len) / total, acc + len), 0);

function draw(p) {
  let done = 0;
  inks.forEach((path, i) => {
    const start = i === 0 ? 0 : ends[i - 1];
    const t = Math.min(1, Math.max(0, (p - start) / (ends[i] - start)));
    path.style.strokeDasharray = '1';
    path.style.strokeDashoffset = String(1 - t);
    // 長さ 0 の線も丸い端の点が残るので、書き始める前は隠す。
    path.style.visibility = t > 0 ? 'visible' : 'hidden';
    if (t >= 1) done = i + 1;
    if (t > 0 && t < 1) {
      // 筆先の点を、いま書いている画の先端へ。
      const pt = path.getPointAtLength(lens[i] * t);
      // 点は、その画と同じ座標系(親の <g>)へ移して置く。
      if (tip.parentNode !== path.parentNode) path.parentNode.append(tip);
      tip.setAttribute('cx', pt.x.toFixed(2));
      tip.setAttribute('cy', pt.y.toFixed(2));
      tip.style.opacity = '1';
      done = i + 1;
    }
  });
  if (p <= 0 || p >= 1) tip.style.opacity = '0';
  countNo.textContent = String(done).padStart(2, '0');
}

if (dvReduce) {
  countNo.textContent = String(inks.length).padStart(2, '0');
} else {
  // 書き始める前に少し間を置き、書き終えたあとも少し残す。
  dvProgress((el, p) => draw(Math.min(1, Math.max(0, (p - 0.08) / 0.78))));
}
</script>
</body>
</html>

02 motion-text-shatter/demo.html

12.8 KB

/designs/motion-text-shatter/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>46 粒子ばらけ — Text disintegration into particles</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); }
.pt-zone { height: 400vh; }
.pt-stick {
  position: sticky; top: 0; height: 100vh;
  display: grid; place-content: center; justify-items: center; gap: var(--s4);
}
/* 幅は画面から決める。中央寄せの grid の中で % を使うと箱が点まで縮む。 */
.pt-canvas { display: block; width: 100vw; aspect-ratio: 16 / 7; }
@media (max-width: 640px) { .pt-canvas { aspect-ratio: 1 / 1; } }
.pt-text { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; text-align: center; }
/* JS が動くあいだは文字を粒で描くので、本物の文は読み上げ用に残して見えなくする。 */
.js .pt-text {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.pt-count { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .pt-zone { height: auto; }
  .pt-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="46">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">46</p>
    <h1 class="dv-name">粒子ばらけ</h1>
    <p class="dv-ai">Text disintegration into particles</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="pt-zone" data-progress="pin">
    <div class="pt-stick">
      <h2 class="pt-text">言葉は、風に散る。</h2>
      <canvas class="pt-canvas" aria-hidden="true"></canvas>
      <p class="pt-count">粒 0</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 — 46 粒子ばらけ</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 ---- */
const canvas = document.querySelector('.pt-canvas');
const countEl = document.querySelector('.pt-count');
const text = document.querySelector('.pt-text').textContent.trim();
const ctx = canvas.getContext('2d');
const role = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();

// 毎回同じ並びになる乱数。スクロールを戻したとき、粒が同じ道を帰るために使う。
function seeded(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

let W = 0, H = 0, parts = [], progress = 0, queued = false;

/** 文字を描いて画素を拾い、粒の「もとの位置」を決める。 */
function sample() {
  W = canvas.clientWidth; H = canvas.clientHeight;
  const dpr = Math.min(2, window.devicePixelRatio || 1);
  canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
  const off = document.createElement('canvas');
  off.width = W; off.height = H;
  const o = off.getContext('2d', { willReadFrequently: true });
  o.fillStyle = role('--ink');
  // 狭い画面では読点で 2 行に割り、文字を大きく保つ。
  const lines = W < 640 ? text.split(/(?<=、)/) : [text];
  const longest = Math.max(...lines.map((l) => l.length));
  const size = Math.round(Math.min((W * 0.86) / (longest + 0.4), H * 0.7 / lines.length));
  o.font = `700 ${size}px ${getComputedStyle(document.body).fontFamily}`;
  o.textAlign = 'center'; o.textBaseline = 'middle';
  lines.forEach((l, i) => o.fillText(l, W / 2, H / 2 + (i - (lines.length - 1) / 2) * size * 1.25));
  const data = o.getImageData(0, 0, W, H).data;
  const rnd = seeded(7);
  for (let step = 2; ; step++) {
    parts = [];
    for (let y = 0; y < H; y += step) {
      for (let x = 0; x < W; x += step) {
        if (data[(y * W + x) * 4 + 3] > 128) {
          parts.push({
            x, y, s: step,
            a: rnd() * Math.PI * 2,   // 飛ぶ向きのばらつき
            r: 0.4 + rnd() * 0.9,     // 飛ぶ距離の倍率
            k: rnd(),                 // 散り方・色分け用
          });
        }
      }
    }
    if (parts.length <= 9000 || step > 12) break;
  }
  countEl.textContent = `粒 ${parts.length.toLocaleString('en-US')}`;
}

const clamp01 = (n) => Math.min(1, Math.max(0, n));
const easeIn = (t) => t * t * (3 - 2 * t) * t;   // 出だしはゆっくり、風に乗ると加速

function render() {
  queued = false;
  const dpr = canvas.width / W;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, W, H);
  const ink = role('--ink'), accent = role('--accent');
  // 最初の 12% は文字のまま見せ、そこから左の粒に風が当たっていく。
  const e = clamp01((progress - 0.12) / 0.78);
  for (const q of parts) {
    const delay = (q.x / W) * 0.45;
    const t = clamp01((e - delay) / 0.55);
    if (t <= 0) {
      ctx.fillStyle = q.k > 0.9 ? accent : ink;
      ctx.fillRect(q.x, q.y, q.s * 0.9, q.s * 0.9);
      continue;
    }
    const m = easeIn(t);
    const dx = m * W * (0.22 + q.r * 0.3) + Math.cos(q.a) * m * 60 * q.r;   // 風下(右)へ
    const dy = -m * H * 0.55 * q.r + Math.sin(q.a) * m * 50 * q.r + m * m * 40;  // 上へ昇り、少し落ちる
    const alpha = 1 - t * t;
    if (alpha <= 0.01) continue;
    const size = q.s * 0.9 * (1 - 0.6 * t);
    ctx.globalAlpha = alpha;
    ctx.fillStyle = q.k > 0.9 ? accent : ink;
    ctx.fillRect(q.x + dx, q.y + dy, size, size);
  }
  ctx.globalAlpha = 1;
}

const request = () => { if (!queued) { queued = true; requestAnimationFrame(render); } };
// 画面の大きさが変わったときだけ、間引いて拾い直す。モバイルでは URL バーの出入りでも
// resize が何度も飛ぶ。
let lastSize = '';
let resampling = false;
function resample() {
  const size = canvas.clientWidth + 'x' + canvas.clientHeight;
  if (size === lastSize || resampling) return;
  resampling = true;
  requestAnimationFrame(() => { resampling = false; lastSize = canvas.clientWidth + 'x' + canvas.clientHeight; sample(); request(); });
}
function start() {
  sample();
  lastSize = canvas.clientWidth + 'x' + canvas.clientHeight;
  addEventListener('resize', resample);
  if (dvReduce) { progress = 0; request(); return; }   // 動きを減らす設定では、文字のまま止める
  dvProgress((el, p) => { progress = p; request(); });
}
// 書体が読み込まれる前に画素を拾うと、別の書体の形を粒にしてしまう。
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(start);
</script>
</body>
</html>

03 motion-text-gather/demo.html

12.9 KB

/designs/motion-text-gather/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>47 粒子集結 — Particles assemble into text</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); }
.pt-zone { height: 400vh; }
.pt-stick {
  position: sticky; top: 0; height: 100vh;
  display: grid; place-content: center; justify-items: center; gap: var(--s4);
}
/* 幅は画面から決める。中央寄せの grid の中で % を使うと箱が点まで縮む。 */
.pt-canvas { display: block; width: 100vw; aspect-ratio: 16 / 7; }
@media (max-width: 640px) { .pt-canvas { aspect-ratio: 1 / 1; } }
.pt-text { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; text-align: center; }
/* JS が動くあいだは文字を粒で描くので、本物の文は読み上げ用に残して見えなくする。 */
.js .pt-text {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.pt-count { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .pt-zone { height: auto; }
  .pt-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="47">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">47</p>
    <h1 class="dv-name">粒子集結</h1>
    <p class="dv-ai">Particles assemble into text</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="pt-zone" data-progress="pin">
    <div class="pt-stick">
      <h2 class="pt-text">言葉は、粒から生まれる。</h2>
      <canvas class="pt-canvas" aria-hidden="true"></canvas>
      <p class="pt-count">粒 0</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 — 47 粒子集結</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 ---- */
const canvas = document.querySelector('.pt-canvas');
const countEl = document.querySelector('.pt-count');
const text = document.querySelector('.pt-text').textContent.trim();
const ctx = canvas.getContext('2d');
const role = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();

// 毎回同じ並びになる乱数。スクロールを戻したとき、粒が同じ道を帰るために使う。
function seeded(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

let W = 0, H = 0, parts = [], progress = 0, queued = false;

/** 文字を描いて画素を拾い、粒の「行き先」を決める。出発点は行き先ごとに散らす。 */
function sample() {
  W = canvas.clientWidth; H = canvas.clientHeight;
  const dpr = Math.min(2, window.devicePixelRatio || 1);
  canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
  const off = document.createElement('canvas');
  off.width = W; off.height = H;
  const o = off.getContext('2d', { willReadFrequently: true });
  o.fillStyle = role('--ink');
  // 狭い画面では読点で 2 行に割り、文字を大きく保つ。
  const lines = W < 640 ? text.split(/(?<=、)/) : [text];
  const longest = Math.max(...lines.map((l) => l.length));
  const size = Math.round(Math.min((W * 0.86) / (longest + 0.4), H * 0.7 / lines.length));
  o.font = `700 ${size}px ${getComputedStyle(document.body).fontFamily}`;
  o.textAlign = 'center'; o.textBaseline = 'middle';
  lines.forEach((l, i) => o.fillText(l, W / 2, H / 2 + (i - (lines.length - 1) / 2) * size * 1.25));
  const data = o.getImageData(0, 0, W, H).data;
  const rnd = seeded(11);
  for (let step = 2; ; step++) {
    parts = [];
    for (let y = 0; y < H; y += step) {
      for (let x = 0; x < W; x += step) {
        if (data[(y * W + x) * 4 + 3] > 128) {
          // 出発点は、画面の中心のまわりの大きな輪の上。
          const a = rnd() * Math.PI * 2, r = (0.55 + rnd() * 0.75) * W * 0.5;
          parts.push({
            x, y, s: step,
            sx: W / 2 + Math.cos(a) * r,
            sy: H / 2 + Math.sin(a) * r * 0.7,
            d: rnd() * 0.4,           // 出発の遅れ
            w: (rnd() - 0.5) * 2,     // 渦の向きと強さ
            k: rnd(),
          });
        }
      }
    }
    if (parts.length <= 9000 || step > 12) break;
  }
  countEl.textContent = `粒 ${parts.length.toLocaleString('en-US')}`;
}

const clamp01 = (n) => Math.min(1, Math.max(0, n));
const ease = (t) => 1 - Math.pow(1 - t, 3);

function render() {
  queued = false;
  const dpr = canvas.width / W;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, W, H);
  const ink = role('--ink'), accent = role('--accent');
  // 先頭の 6% は散らばったまま、70% までに集まり、あとは文字のまま残る。
  const e = clamp01((progress - 0.06) / 0.64);
  for (const q of parts) {
    const t = clamp01((e - q.d) / 0.6);
    const m = ease(t);
    // 出発点と行き先を結ぶ直線から、横へふくらむ弧(渦)。着く手前でゼロに戻る。
    const bx = q.sx + (q.x - q.sx) * m, by = q.sy + (q.y - q.sy) * m;
    const bulge = Math.sin(m * Math.PI) * q.w * 90;
    const nx = -(q.y - q.sy), ny = q.x - q.sx, len = Math.hypot(nx, ny) || 1;
    ctx.globalAlpha = Math.min(1, 0.25 + t * 3);
    ctx.fillStyle = q.k > 0.9 && t < 1 ? accent : ink;
    ctx.beginPath();
    ctx.arc(bx + (nx / len) * bulge + q.s * 0.45, by + (ny / len) * bulge + q.s * 0.45, q.s * (0.35 + 0.2 * m), 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.globalAlpha = 1;
}

const request = () => { if (!queued) { queued = true; requestAnimationFrame(render); } };
// 画面の大きさが変わったときだけ、間引いて拾い直す。モバイルでは URL バーの出入りでも
// resize が何度も飛ぶ。
let lastSize = '';
let resampling = false;
function resample() {
  const size = canvas.clientWidth + 'x' + canvas.clientHeight;
  if (size === lastSize || resampling) return;
  resampling = true;
  requestAnimationFrame(() => { resampling = false; lastSize = canvas.clientWidth + 'x' + canvas.clientHeight; sample(); request(); });
}
function start() {
  sample();
  lastSize = canvas.clientWidth + 'x' + canvas.clientHeight;
  addEventListener('resize', resample);
  if (dvReduce) { progress = 1; request(); return; }   // 動きを減らす設定では、集まった文字で止める
  dvProgress((el, p) => { progress = p; request(); });
}
// 書体が読み込まれる前に画素を拾うと、別の書体の形を粒にしてしまう。
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(start);
</script>
</body>
</html>

04 motion-char-wave/demo.html

9.1 KB

/designs/motion-char-wave/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>48 文字の波 — Character wave on scroll</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); }
.cw-zone { height: 360vh; }
.cw-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s5); }
.cw-line { display: flex; justify-content: center; font-size: clamp(24px, 7vw, 92px); font-weight: 700; line-height: 1.25; }
.cw-line--sub { font-size: clamp(14px, 3.4vw, 36px); color: var(--muted); }
.cw-ch { display: inline-block; will-change: transform; transition: color .2s linear; }
.cw-ch.is-crest { color: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  .cw-zone { height: auto; }
  .cw-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="48">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">48</p>
    <h1 class="dv-name">文字の波</h1>
    <p class="dv-ai">Character wave on scroll</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="cw-zone" data-progress="pin">
    <div class="cw-stick">
      <div class="dv-wrap">
        <p class="cw-line" data-wave="1" role="img" aria-label="波のように言葉が揺れる">波のように言葉が揺れる</p>
        <p class="cw-line cw-line--sub" data-wave="-1" role="img" aria-label="The words sway like waves">The words sway like waves</p>
      </div>
    </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 — 48 文字の波</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 ---- */
// 1 字ずつ <span> に割る。本物の文は aria-label に残し、割った字は読み上げから外す。
const lines = [...document.querySelectorAll('[data-wave]')].map((el) => {
  const text = el.textContent;
  el.textContent = '';
  const chars = [...text].map((ch) => {
    const s = document.createElement('span');
    s.className = 'cw-ch';
    s.setAttribute('aria-hidden', 'true');
    s.textContent = ch === ' ' ? ' ' : ch;
    el.append(s);
    return s;
  });
  return { chars, dir: Number(el.dataset.wave) };
});

// 波は 2 回、列を通り抜ける。進みの両端では振れ幅 0 に絞って、止まった文字は一直線に戻す。
const TURNS = 2;
function place(p) {
  const env = Math.sin(Math.PI * p);
  for (const { chars, dir } of lines) {
    const n = chars.length;
    chars.forEach((c, i) => {
      const phase = (p * TURNS * 2 * Math.PI) * dir - (i / n) * 2 * Math.PI * 1.1;
      const s = Math.sin(phase);
      c.style.transform = `translateY(${(-s * 0.32 * env).toFixed(3)}em) scale(${(1 + 0.18 * env * Math.max(0, s)).toFixed(3)})`;
      c.classList.toggle('is-crest', env > 0.15 && s > 0.8);
    });
  }
}
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

05 motion-text-on-path/demo.html

9.2 KB

/designs/motion-text-on-path/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>49 曲線に沿う文字 — Text on a path</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); }
.tp-zone { height: 360vh; }
.tp-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }
/* 幅は画面から決める。 */
.tp-svg { display: block; width: 100vw; height: auto; max-height: 90vh; }
.tp-rail { fill: none; stroke: var(--rule); stroke-width: 2; stroke-dasharray: 2 10; stroke-linecap: round; }
.tp-text { font-family: var(--font); font-size: 64px; font-weight: 700; fill: var(--ink); letter-spacing: .04em; }
.tp-text .tp-em { fill: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  .tp-zone { height: auto; }
  .tp-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="49">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">49</p>
    <h1 class="dv-name">曲線に沿う文字</h1>
    <p class="dv-ai">Text on a path</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="tp-zone" data-progress="pin">
    <div class="tp-stick">
      <svg class="tp-svg" viewBox="0 0 1200 560" role="img" aria-label="言葉は曲がりくねった道をたどって、遠くへ届く。" xmlns="http://www.w3.org/2000/svg">
        <path id="tp-rail" class="tp-rail" d="M-40 440 C 160 440, 220 120, 420 120 S 640 440, 820 440 S 1040 160, 1240 120"/>
        <text class="tp-text" aria-hidden="true"><textPath href="#tp-rail" startOffset="8%">言葉は曲がりくねった道をたどって、<tspan class="tp-em">遠くへ</tspan>届く。</textPath></text>
      </svg>
    </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 — 49 曲線に沿う文字</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 ---- */
const tp = document.querySelector('.tp-text textPath');
const rail = document.getElementById('tp-rail');
const textEl = document.querySelector('.tp-text');
const railLen = rail.getTotalLength();
let textLen = textEl.getComputedTextLength();

// 文の頭が道の終点にあるところから、文の最後が道の始点を抜けるまで。
function place(p) {
  tp.setAttribute('startOffset', (railLen - p * (railLen + textLen)).toFixed(1));
}
// 文の長さは書体で変わるので、書体が読み込まれてから測る。JS が動かないときは、
// マークアップの startOffset(8%)のまま、文が道に乗って読める。
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => {
  textLen = textEl.getComputedTextLength();
  if (dvReduce) {
    // 動きを減らす設定では、文全体が道に乗った位置で止める。
    tp.setAttribute('startOffset', Math.max(0, (railLen - textLen) / 2).toFixed(1));
  } else {
    dvProgress((el, p) => place(p));
  }
});
</script>
</body>
</html>

06 motion-vertical-write/demo.html

9.8 KB

/designs/motion-vertical-write/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>50 縦書きの筆記 — Vertical writing 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); }
.vw-zone { height: 380vh; }
.vw-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }
/* 原稿用紙の升目。行の幅と字の送りを同じ長さに揃えて、罫線が字の位置に合うようにする。 */
.vw-sheet {
  --cell: clamp(30px, 6.2vw, 56px);
  writing-mode: vertical-rl;
  display: block;
  width: max-content;
  margin-inline: auto;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background:
    linear-gradient(to bottom, var(--rule) 1px, transparent 1px) 0 0 / 100% var(--cell),
    linear-gradient(to left, var(--rule) 1px, transparent 1px) 0 0 / var(--cell) 100%;
  background-color: var(--surface);
  font-size: calc(var(--cell) * .72);
  font-weight: 700;
  line-height: var(--cell);
  letter-spacing: calc(var(--cell) * .28);
  max-width: 100%;
}
.vw-col { margin: 0; height: calc(var(--cell) * 9); width: var(--cell); text-align: center; white-space: nowrap; }
.vw-ch { display: inline-block; }
.js .vw-ch { opacity: 0; transform: translateY(-.35em); filter: blur(4px); transition: opacity .5s var(--ease), transform .5s var(--ease), filter .5s var(--ease); }
.js .vw-ch.is-on { opacity: 1; transform: none; filter: none; }
.vw-ch.is-head { color: var(--accent); }
.vw-count { margin-top: var(--s3); text-align: center; font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .vw-zone { height: auto; }
  .vw-stick { position: static; height: auto; padding-block: var(--s6); }
  .js .vw-ch { opacity: 1; transform: none; filter: none; transition: none; }
}
</style>
</head>
<body data-motion-number="50">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">50</p>
    <h1 class="dv-name">縦書きの筆記</h1>
    <p class="dv-ai">Vertical writing 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="vw-zone" data-progress="pin">
    <div class="vw-stick">
      <div>
        <div class="vw-sheet" lang="ja">
          <p class="vw-col">静かな朝の窓辺に</p>
          <p class="vw-col">言葉は上から下へ</p>
          <p class="vw-col">右から左へと流れる</p>
        </div>
        <p class="vw-count">25 / 25</p>
      </div>
    </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 — 50 縦書きの筆記</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 ---- */
const cols = [...document.querySelectorAll('.vw-col')];
const countEl = document.querySelector('.vw-count');
// 1 字ずつ <span> に割る。行の順(右→左)と字の順(上→下)が、そのまま書く順になる。
const chars = [];
for (const col of cols) {
  const text = col.textContent;
  col.setAttribute('role', 'img');
  col.setAttribute('aria-label', text);
  col.textContent = '';
  for (const ch of text) {
    const s = document.createElement('span');
    s.className = 'vw-ch';
    s.setAttribute('aria-hidden', 'true');
    s.textContent = ch;
    col.append(s);
    chars.push(s);
  }
}
const set = (n) => {
  chars.forEach((c, i) => {
    c.classList.toggle('is-on', i < n);
    c.classList.toggle('is-head', i === n - 1 && n < chars.length);
  });
  countEl.textContent = `${n} / ${chars.length}`;
};
if (dvReduce) set(chars.length);
else dvProgress((el, p) => set(Math.round(Math.min(1, p / 0.85) * chars.length)));
</script>
</body>
</html>

07 motion-letters-scatter/demo.html

9.6 KB

/designs/motion-letters-scatter/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>51 文字の集合 — Scattered letters snap into place</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); }
.ls-zone { height: 360vh; }
.ls-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
.ls-line { display: flex; justify-content: center; font-size: clamp(30px, 9vw, 120px); font-weight: 700; line-height: 1.2; }
.ls-line--sub { font-size: clamp(18px, 3.4vw, 36px); color: var(--muted); }
.ls-ch { display: inline-block; will-change: transform, opacity; color: var(--accent); transition: color .3s var(--ease); }
.ls-ch.is-set { color: var(--ink); }
.ls-line--sub .ls-ch.is-set { color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  .ls-ch { color: var(--ink); }
  .ls-zone { height: auto; }
  .ls-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="51">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">51</p>
    <h1 class="dv-name">文字の集合</h1>
    <p class="dv-ai">Scattered letters snap into place</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="ls-zone" data-progress="pin">
    <div class="ls-stick">
      <div class="dv-wrap">
        <p class="ls-line" data-scatter role="img" aria-label="言葉が集まる">言葉が集まる</p>
        <p class="ls-line ls-line--sub" data-scatter role="img" aria-label="Letters find their place">Letters find their place</p>
      </div>
    </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 — 51 文字の集合</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 ---- */
function seeded(seed) {
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rnd = seeded(5);
// 1 字ずつ <span> に割り、散らばった姿(位置・回転・奥行き)を字ごとに決めておく。
const chars = [];
document.querySelectorAll('[data-scatter]').forEach((el) => {
  const text = el.textContent;
  el.textContent = '';
  for (const ch of text) {
    const s = document.createElement('span');
    s.className = 'ls-ch';
    s.setAttribute('aria-hidden', 'true');
    s.textContent = ch === ' ' ? ' ' : ch;
    el.append(s);
    chars.push({
      el: s,
      x: (rnd() - 0.5) * 2 * 42,    // vw
      y: (rnd() - 0.5) * 2 * 42,    // vh
      r: (rnd() - 0.5) * 2 * 260,   // deg
      ry: (rnd() - 0.5) * 2 * 120,
      s: 0.5 + rnd() * 2.2,
      d: rnd() * 0.35,              // 戻り始めの遅れ
    });
  }
});
const easeOut = (t) => 1 - Math.pow(1 - t, 4);
function place(p) {
  for (const c of chars) {
    const t = Math.min(1, Math.max(0, (p / 0.8 - c.d) / 0.65));
    const k = 1 - easeOut(t);          // 1 = 散っている、0 = 揃った
    c.el.style.transform = `perspective(900px) translate(${(c.x * k).toFixed(2)}vw, ${(c.y * k).toFixed(2)}vh) rotate(${(c.r * k).toFixed(1)}deg) rotateY(${(c.ry * k).toFixed(1)}deg) scale(${(1 + (c.s - 1) * k).toFixed(3)})`;
    c.el.style.opacity = String(Math.min(1, 0.15 + t * 2));
    c.el.classList.toggle('is-set', t >= 1);
  }
}
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

08 motion-stretch-stroke/demo.html

9.0 KB

/designs/motion-stretch-stroke/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>52 一部を伸ばす — Stretch one stroke of a title</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); }
.ss-zone { height: 320vh; }
.ss-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
.ss-title { display: flex; align-items: center; font-size: clamp(40px, 10vw, 128px); font-weight: 700; line-height: 1.2; }
/* 「一」の画。右端を文字に付けたまま、左へ伸ばす。 */
.ss-bar {
  flex: none;
  width: 1em;
  height: .09em;
  margin-right: .04em;
  background: var(--accent);
  transform-origin: right center;
  transform: scaleX(calc(1 + var(--p, 0) * 14));
}
.ss-sub { font-family: var(--mono); font-size: var(--t-label); letter-spacing: .12em; color: var(--muted); }
.ss-wrap { display: grid; gap: var(--s3); justify-items: start; margin-inline: auto; width: max-content; max-width: 100%; }

@media (prefers-reduced-motion: reduce) {
  .ss-zone { height: auto; }
  .ss-stick { position: static; height: auto; padding-block: var(--s6); }
  .ss-bar { transform: scaleX(6); }
}
</style>
</head>
<body data-motion-number="52">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">52</p>
    <h1 class="dv-name">一部を伸ばす</h1>
    <p class="dv-ai">Stretch one stroke of a title</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="ss-zone" data-progress="pin">
    <div class="ss-stick">
      <div class="ss-wrap">
        <h2 class="ss-title" aria-label="一部を伸ばす"><span class="ss-bar" aria-hidden="true"></span><span aria-hidden="true">部を伸ばす</span></h2>
        <p class="ss-sub">STRETCH ONE STROKE</p>
      </div>
    </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 — 52 一部を伸ばす</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 ---- */
// 伸びる量は、画面の左端に届くまで。画面が狭いと 1em が小さいので、倍率は幅から決める。
const bar = document.querySelector('.ss-bar');
function fit() {
  const em = parseFloat(getComputedStyle(bar).fontSize);
  const reach = bar.getBoundingClientRect().right;           // 右端から画面の左端まで
  document.querySelector('.ss-stick').style.setProperty('--reach', String(Math.max(2, reach / em)));
}
if (!dvReduce) {
  bar.style.transform = 'scaleX(calc(1 + var(--p, 0) * (var(--reach, 14) - 1)))';
  fit();
  addEventListener('resize', fit);
  dvProgress((el, p) => {
    // 先頭と終わりに少し間を置く。
    const q = Math.min(1, Math.max(0, (p - 0.1) / 0.7));
    el.style.setProperty('--p', q.toFixed(4));
  });
}
</script>
</body>
</html>

09 motion-gradient-sweep/demo.html

8.5 KB

/designs/motion-gradient-sweep/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>53 グラデ文字 — Gradient text sweep</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); }
.gs-stage { display: grid; gap: var(--s7); }
.gs-row { display: grid; gap: var(--s3); }
.gs-text {
  font-size: clamp(44px, 12vw, 148px);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: .02em;
  color: var(--accent);
  /* 地の色・アクセント・インクを混ぜた帯。--p で帯の位置を動かす。 */
  background-image: linear-gradient(100deg,
    var(--ink) 0%, var(--ink) 18%,
    var(--accent) 42%,
    var(--accent-mid) 52%,
    var(--accent) 62%,
    var(--ink) 86%, var(--ink) 100%);
  background-size: 280% 100%;
  background-position: calc(100% - var(--p, 0) * 100%) 0;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  width: fit-content;
}
.gs-text--b { background-position: calc(var(--p, 0) * 100%) 0; }
.gs-cap { font-family: var(--mono); font-size: var(--t-label); letter-spacing: .12em; color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  .gs-text, .gs-text--b { background-position: 50% 0; }
}
</style>
</head>
<body data-motion-number="53">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">53</p>
    <h1 class="dv-name">グラデ文字</h1>
    <p class="dv-ai">Gradient text sweep</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>

  <div class="dv-wrap gs-stage">
    <div class="gs-row" data-progress="through">
      <p class="gs-text">グラデ文字</p>
      <p class="gs-cap">GRADIENT → 右へ</p>
    </div>
    <div class="gs-row" data-progress="through">
      <p class="gs-text gs-text--b">色が流れる</p>
      <p class="gs-cap">GRADIENT ← 左へ</p>
    </div>
    <div class="gs-row" data-progress="through">
      <p class="gs-text">光が走る</p>
      <p class="gs-cap">GRADIENT → 右へ</p>
    </div>
  </div>

  <div class="dv-wrap stack" style="margin-top: var(--s7)">
    <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 — 53 グラデ文字</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 ---- */
// 位置は CSS の --p だけで決まる。JS は dvProgress に書かせるだけ。
if (!dvReduce) dvProgress();
</script>
</body>
</html>

10 motion-erase-part/demo.html

8.9 KB

/designs/motion-erase-part/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>54 一部を消す — Partially erased letters</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); }
.ep-zone { height: 340vh; }
.ep-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
.ep-title { display: flex; justify-content: center; gap: .18em; font-size: clamp(40px, 11vw, 140px); font-weight: 700; line-height: 1.2; }
.ep-ch { position: relative; display: inline-block; --e: 0; }
/* 字の下から、--e の分だけ消す。0.62em は字の高さのおよそ半分。 */
.ep-glyph { display: block; clip-path: inset(0 0 calc(var(--e) * .62em) 0); }
.ep-cut { position: absolute; left: -.06em; right: -.06em; bottom: .04em; height: 2px; background: var(--accent); transform: translateY(calc(var(--e) * -.62em)); opacity: calc(var(--e) * 4); }
.ep-cap { text-align: center; font-family: var(--mono); font-size: var(--t-label); letter-spacing: .12em; color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
  .ep-zone { height: auto; }
  .ep-stick { position: static; height: auto; padding-block: var(--s6); }
  .ep-ch { --e: .7; }
}
</style>
</head>
<body data-motion-number="54">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">54</p>
    <h1 class="dv-name">一部を消す</h1>
    <p class="dv-ai">Partially erased letters</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="ep-zone" data-progress="pin">
    <div class="ep-stick">
      <h2 class="ep-title" data-erase aria-label="一部を消す">一部を消す</h2>
      <p class="ep-cap">ERASE PART OF EACH LETTER</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 — 54 一部を消す</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 ---- */
const title = document.querySelector('[data-erase]');
const text = title.textContent;
title.textContent = '';
const chars = [...text].map((ch) => {
  const wrap = document.createElement('span');
  wrap.className = 'ep-ch';
  wrap.setAttribute('aria-hidden', 'true');
  wrap.innerHTML = '<span class="ep-glyph"></span><span class="ep-cut"></span>';
  wrap.firstChild.textContent = ch;
  title.append(wrap);
  return wrap;
});
// 左の字から 1 字ずつ時間差で消し始め、半分まで消えたら止める。
function place(p) {
  const n = chars.length;
  chars.forEach((c, i) => {
    const start = 0.1 + (i / n) * 0.45;
    const e = Math.min(1, Math.max(0, (p - start) / 0.3));
    c.style.setProperty('--e', e.toFixed(3));
  });
}
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

11 motion-sparkle-scatter/demo.html

9.8 KB

/designs/motion-sparkle-scatter/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>55 装飾を散らす — Sparkle ornaments around a title</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); }
.sp-zone { height: 340vh; }
.sp-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; overflow: clip; }
.sp-box { position: relative; padding: var(--s6) var(--s5); }
.sp-title { position: relative; font-size: clamp(36px, 9.6vw, 120px); font-weight: 700; line-height: 1.2; text-align: center; }
.sp-star { position: absolute; left: var(--x); top: var(--y); width: var(--w); height: var(--w); margin: calc(var(--w) / -2) 0 0 calc(var(--w) / -2); fill: var(--accent); }
.sp-star--ink { fill: var(--ink); }
.sp-star { transform: scale(var(--s, 0)) rotate(var(--r, 0deg)); opacity: var(--o, 0); }

@media (prefers-reduced-motion: reduce) {
  .sp-zone { height: auto; }
  .sp-stick { position: static; height: auto; padding-block: var(--s6); }
  .sp-star { transform: none; opacity: 1; }
}
</style>
</head>
<body data-motion-number="55">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">55</p>
    <h1 class="dv-name">装飾を散らす</h1>
    <p class="dv-ai">Sparkle ornaments around a title</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="sp-zone" data-progress="pin">
    <div class="sp-stick">
      <div class="sp-box">
        <h2 class="sp-title">装飾を散らす</h2>
        <div class="sp-stars" aria-hidden="true"></div>
      </div>
    </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 — 55 装飾を散らす</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 ---- */
const NS = 'http://www.w3.org/2000/svg';
const host = document.querySelector('.sp-stars');
// 見出しの外側に置く星。x・y は箱の中の割合、w は大きさ、d は現れ始める進み。
const SPEC = [
  { x: '2%', y: '10%', w: '34px', d: 0.08 }, { x: '14%', y: '-4%', w: '18px', d: 0.16, ink: 1 },
  { x: '36%', y: '-8%', w: '26px', d: 0.24 }, { x: '62%', y: '-6%', w: '16px', d: 0.32, ink: 1 },
  { x: '84%', y: '-2%', w: '40px', d: 0.4 }, { x: '98%', y: '22%', w: '20px', d: 0.48 },
  { x: '102%', y: '58%', w: '30px', d: 0.56, ink: 1 }, { x: '90%', y: '104%', w: '22px', d: 0.64 },
  { x: '66%', y: '108%', w: '34px', d: 0.7 }, { x: '40%', y: '106%', w: '16px', d: 0.76, ink: 1 },
  { x: '16%', y: '110%', w: '28px', d: 0.82 }, { x: '-2%', y: '76%', w: '18px', d: 0.88 },
];
const stars = SPEC.map((s) => {
  const svg = document.createElementNS(NS, 'svg');
  svg.setAttribute('viewBox', '-10 -10 20 20');
  svg.setAttribute('class', 'sp-star' + (s.ink ? ' sp-star--ink' : ''));
  svg.style.setProperty('--x', s.x); svg.style.setProperty('--y', s.y); svg.style.setProperty('--w', s.w);
  const path = document.createElementNS(NS, 'path');
  path.setAttribute('d', 'M0 -10 C1 -3 3 -1 10 0 C3 1 1 3 0 10 C-1 3 -3 1 -10 0 C-3 -1 -1 -3 0 -10Z');
  svg.append(path);
  host.append(svg);
  return { el: svg, d: s.d };
});
const easeOutBack = (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2);
function place(p) {
  const q = p / 0.9;
  for (const s of stars) {
    const t = Math.min(1, Math.max(0, (q - s.d) / 0.14));
    s.el.style.setProperty('--s', easeOutBack(t).toFixed(3));
    s.el.style.setProperty('--o', Math.min(1, t * 3).toFixed(3));
    s.el.style.setProperty('--r', (t * 90 + p * 40).toFixed(1) + 'deg');   // 現れるときに回り、そのあともゆっくり回る
  }
}
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

12 motion-slash-strike/demo.html

9.7 KB

/designs/motion-slash-strike/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>56 斜線を入れる — Diagonal slashes through a title</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); }
.sl-zone { height: 340vh; }
.sl-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
@media (prefers-reduced-motion: reduce) {
  .sl-zone { height: auto; }
  .sl-stick { position: static; height: auto; padding-block: var(--s6); }
}
.sl-word { position: relative; width: max-content; margin-inline: auto; padding-inline: .3em; font-size: clamp(34px, 9.4vw, 120px); font-weight: 700; line-height: 1.5; letter-spacing: .2em; }
.sl-lines { position: absolute; inset: -.1em 0; width: 100%; height: calc(100% + .2em); overflow: visible; pointer-events: none; }
.sl-line { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; }
.sl-cap { text-align: center; font-family: var(--mono); font-size: var(--t-label); letter-spacing: .12em; color: var(--muted); }
</style>
</head>
<body data-motion-number="56">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">56</p>
    <h1 class="dv-name">斜線を入れる</h1>
    <p class="dv-ai">Diagonal slashes through a title</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="sl-zone" data-progress="pin">
    <div class="sl-stick">
      <h2 class="sl-word" aria-label="斜線を入れる">斜線を入れる
        <svg class="sl-lines" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"></svg>
      </h2>
      <p class="sl-cap">DIAGONAL SLASHES</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 — 56 斜線を入れる</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 ---- */
// 文字の間(と両端)に、右上から左下へ向かう斜線を 7 本引く。
// 座標は見出しの実寸(px)にそろえる。引き伸ばした座標だと、線の太さと破線の長さが歪む。
const svg = document.querySelector('.sl-lines');
const NS = 'http://www.w3.org/2000/svg';
const N = 7;
const slashes = [];
for (let i = 0; i < N; i++) {
  const path = document.createElementNS(NS, 'path');
  path.setAttribute('class', 'sl-line');
  path.setAttribute('pathLength', '1');
  path.style.strokeDasharray = '1';
  svg.append(path);
  slashes.push(path);
}
function layout() {
  const box = svg.getBoundingClientRect();   // 重ねた SVG 自身の実寸
  const w = Math.round(box.width), h = Math.round(box.height);
  svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
  const lean = h * 0.14;
  slashes.forEach((path, i) => {
    const x = w * (0.03 + (0.94 * i) / (N - 1));
    path.setAttribute('d', `M${(x + lean).toFixed(1)} ${(-h * 0.02).toFixed(1)} L${(x - lean).toFixed(1)} ${(h * 1.02).toFixed(1)}`);
  });
}
const clamp = (n) => Math.min(1, Math.max(0, n));
function place(p) {
  slashes.forEach((s, i) => {
    // 左から順に、1 本ずつ上から下へ引く。
    const t = clamp((p - 0.1 - i * 0.1) / 0.14);
    s.style.strokeDashoffset = String(1 - t);
    s.style.visibility = t > 0 ? 'visible' : 'hidden';
  });
}
layout();
// 画面の回転だけでなく、書体の差し替えや文字の拡大で見出しの大きさが変わったときも取り直す。
if ('ResizeObserver' in window) new ResizeObserver(layout).observe(svg.parentElement);
else addEventListener('resize', layout);
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

13 motion-one-letter-big/demo.html

9.0 KB

/designs/motion-one-letter-big/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>57 一文字だけ大きく — One oversized letter in a title</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); }
.ob-zone { height: 340vh; }
.ob-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
@media (prefers-reduced-motion: reduce) {
  .ob-zone { height: auto; }
  .ob-stick { position: static; height: auto; padding-block: var(--s6); }
}
.ob-title { display: flex; justify-content: center; align-items: flex-end; font-size: clamp(30px, 7.6vw, 96px); font-weight: 700; line-height: 1.2; padding-top: 1.4em; }
.ob-ch { display: inline-block; will-change: transform; }
.ob-ch.is-big { color: var(--accent); transform-origin: 50% 100%; }
.ob-cap { text-align: center; font-family: var(--mono); font-size: var(--t-label); letter-spacing: .12em; color: var(--muted); }
</style>
</head>
<body data-motion-number="57">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">57</p>
    <h1 class="dv-name">一文字だけ大きく</h1>
    <p class="dv-ai">One oversized letter in a title</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="ob-zone" data-progress="pin">
    <div class="ob-stick">
      <h2 class="ob-title" data-onebig="6" aria-label="ひと文字だけ大きく">ひと文字だけ大きく</h2>
      <p class="ob-cap">ONE LETTER, LARGER</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 — 57 一文字だけ大きく</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 ---- */
const title = document.querySelector('[data-onebig]');
const bigIndex = Number(title.dataset.onebig);
const text = title.textContent;
title.textContent = '';
const chars = [...text].map((ch, i) => {
  const s = document.createElement('span');
  s.className = 'ob-ch' + (i === bigIndex ? ' is-big' : '');
  s.setAttribute('aria-hidden', 'true');
  s.textContent = ch;
  title.append(s);
  return s;
});
const SCALE = 2.4;
const easeInOut = (t) => t * t * (3 - 2 * t);
function apply(e) {
  const room = ((SCALE - 1) / 2) * e;   // 大きくなった分の半分(em)を、左右へ空ける
  chars.forEach((c, i) => {
    if (i === bigIndex) c.style.transform = `scale(${(1 + (SCALE - 1) * e).toFixed(3)})`;
    else c.style.transform = `translateX(${((i < bigIndex ? -room : room)).toFixed(3)}em)`;
  });
}
const place = (p) => apply(easeInOut(Math.min(1, Math.max(0, (p - 0.12) / 0.6))));
if (dvReduce) apply(1);   // 動きを減らす設定では、育った状態で止める
else dvProgress((el, p) => place(p));
</script>
</body>
</html>

14 motion-ink-bleed/demo.html

8.9 KB

/designs/motion-ink-bleed/demo.html

中身を見る
<!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>

15 motion-english-underline/demo.html

8.7 KB

/designs/motion-english-underline/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>59 下に英語 — English subtitle under a title</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); }
.eu-zone { height: 340vh; }
.eu-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
@media (prefers-reduced-motion: reduce) {
  .eu-zone { height: auto; }
  .eu-stick { position: static; height: auto; padding-block: var(--s6); }
}
.eu-wrap { width: max-content; max-width: 100%; margin-inline: auto; display: grid; gap: var(--s3); justify-items: center; }
.eu-title { font-size: clamp(44px, 12vw, 150px); font-weight: 700; line-height: 1.15; letter-spacing: .06em; }
.eu-rule { width: 100%; height: 3px; background: var(--ink); transform-origin: left center; transform: scaleX(var(--r, 1)); }
.eu-en { font-family: Georgia, "Times New Roman", serif; font-style: italic; font-size: clamp(14px, 2.6vw, 30px); color: var(--muted); letter-spacing: var(--ls, .14em); opacity: var(--o, 1); white-space: nowrap; width: 0; display: flex; justify-content: center; }
</style>
</head>
<body data-motion-number="59">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">59</p>
    <h1 class="dv-name">下に英語</h1>
    <p class="dv-ai">English subtitle under a title</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="eu-zone" data-progress="pin">
    <div class="eu-stick">
      <div class="eu-wrap">
        <h2 class="eu-title">下に英語</h2>
        <div class="eu-rule" aria-hidden="true"></div>
        <p class="eu-en" lang="en">English language placement</p>
      </div>
    </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 — 59 下に英語</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 ---- */
const stage = document.querySelector('.eu-wrap');
const clamp = (n) => Math.min(1, Math.max(0, n));
const ease = (t) => 1 - Math.pow(1 - t, 3);
function place(p) {
  const rule = ease(clamp((p - 0.12) / 0.3));
  const en = ease(clamp((p - 0.3) / 0.45));
  stage.style.setProperty('--r', rule.toFixed(3));
  stage.style.setProperty('--o', en.toFixed(3));
  stage.style.setProperty('--ls', (0.6 - 0.46 * en).toFixed(3) + 'em');   // 0.6em → 0.14em
}
if (!dvReduce) dvProgress((el, p) => place(p));
</script>
</body>
</html>

16 motion-linked-letters/demo.html

9.4 KB

/designs/motion-linked-letters/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>60 文字を繋げる — Connected letters</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); }
.ll-zone { height: 340vh; }
.ll-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
@media (prefers-reduced-motion: reduce) {
  .ll-zone { height: auto; }
  .ll-stick { position: static; height: auto; padding-block: var(--s6); }
}
.ll-svg { display: block; width: 100vw; height: auto; }
.ll-text { font-family: var(--font); font-size: 150px; font-weight: 700; fill: var(--ink); text-anchor: middle; }
.ll-link { fill: none; stroke: var(--accent-mid); stroke-width: 26; stroke-linecap: round; stroke-linejoin: round; }
</style>
</head>
<body data-motion-number="60">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">60</p>
    <h1 class="dv-name">文字を繋げる</h1>
    <p class="dv-ai">Connected letters</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="ll-zone" data-progress="pin">
    <div class="ll-stick">
      <svg class="ll-svg" viewBox="0 0 1200 300" role="img" aria-label="文字を繋げる" xmlns="http://www.w3.org/2000/svg">
        <path class="ll-link" id="ll-link" pathLength="1" d="" />
        <text class="ll-text" x="600" y="200" aria-hidden="true">文字を繋げる</text>
      </svg>
    </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 — 60 文字を繋げる</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 ---- */
const textEl = document.querySelector('.ll-text');
const link = document.getElementById('ll-link');
const N = [...textEl.textContent].length;

// 各字の外接の中心を拾い、上下に振れる 1 本の曲線で結ぶ。文字の後ろ(SVG の先頭)に描く。
function build() {
  const pts = [];
  for (let i = 0; i < N; i++) {
    const r = textEl.getExtentOfChar(i);
    const x = r.x + r.width / 2;
    const y = r.y + r.height * (i % 2 === 0 ? 0.62 : 0.32);
    pts.push([x, y]);
  }
  // 端は少し外へ延ばす。
  const first = pts[0], last = pts[pts.length - 1];
  const all = [[first[0] - 90, first[1] + 40], ...pts, [last[0] + 90, last[1] - 40]];
  // 隣り合う点を、水平な接線の 3 次ベジェでつなぐ。
  let d = `M${all[0][0].toFixed(1)} ${all[0][1].toFixed(1)}`;
  for (let i = 1; i < all.length; i++) {
    const [x0, y0] = all[i - 1], [x1, y1] = all[i], mx = (x0 + x1) / 2;
    d += ` C${mx.toFixed(1)} ${y0.toFixed(1)} ${mx.toFixed(1)} ${y1.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`;
  }
  link.setAttribute('d', d);
}
const ease = (t) => 1 - Math.pow(1 - t, 2);
function place(p) {
  const t = ease(Math.min(1, Math.max(0, (p - 0.1) / 0.7)));
  link.style.strokeDasharray = '1';
  link.style.strokeDashoffset = String(1 - t);
  link.style.visibility = t > 0 ? 'visible' : 'hidden';
}
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => {
  build();
  if (!dvReduce) dvProgress((el, p) => place(p));
});
</script>
</body>
</html>