Design Vault Search use case, structure, colour, notes /
Index Categories Tags 日本語

Deck

Lettering in Motion 45–60 — From a Single Stroke to Particles

Sixteen effects that write, shatter and gather letters, plus title-lettering touches such as stretching or erasing one part and scattering sparkles, slashing through, bleeding ink and linking letters. They sharpen a headline’s impression as the page scrolls

16 slides

Take it with you JSON However many slides, one action. They are joined in order, so the sequence survives the paste. A slot with no spec carries its title and the address of its JSON

Reading order

  1. 01 Motion Stroke order Stroke order
  2. 02 Motion Text shatter Text shatter
  3. 03 Motion Text gather Text gather
  4. 04 Motion Character wave Character wave
  5. 05 Motion Text on a path Text on a path
  6. 06 Motion Vertical writing Vertical writing
  7. 07 Motion Letters scatter Letters scatter
  8. 08 Motion Stretch a stroke Stretch a stroke
  9. 09 Motion Gradient sweep Gradient sweep
  10. 10 Motion Erase a part Erase a part
  11. 11 Motion Sparkle scatter Sparkle scatter
  12. 12 Motion Diagonal slashes Diagonal slashes
  13. 13 Motion One letter bigger One letter bigger
  14. 14 Motion Ink bleed Ink bleed
  15. 15 Motion English underneath English underneath
  16. 16 Motion Linked letters Linked letters

About this deck

Take it with you

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

Everything

scroll-motion-lettering-everything.md

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

Prompt

21.1 KB
# Stroke order
Stroke order — Stroke-order handwriting

Write the two characters 日 (4 strokes) and 本 (5 strokes) one stroke at a time, in correct stroke order, as the page scrolls. Draw each stroke as an SVG path (stroke-linecap: round, stroke-width 7) with pathLength="1", keeping stroke-dasharray: 1 and moving stroke-dashoffset from 1 to 0. Use 8% to 86% of the progress through a 420vh pinned section, and give each stroke a share of it proportional to its real length so longer strokes take longer. Put an accent-coloured dot at the tip of the stroke being written, and show the stroke count below as "筆順 05 / 09". Lay a faint guide of the strokes and a dotted cross under each character, and with JavaScript off or reduced motion show all nine strokes already written. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Start each path at the point where the brush lands; drawing from the other end reverses the stroke direction
- The order of strokes is the stroke order; finish one stroke before the next begins
- A zero-length line still leaves a round-cap dot, so keep unwritten strokes visibility: hidden
- With reduced motion, stop with every stroke already written

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

---

# Text shatter
Text shatter — Text disintegration into particles

Break a one-line heading into particles and blow them away as the page scrolls. Draw the heading in bold on an off-screen canvas, read its pixels, and place a particle every 2px, widening the step if there would be more than 9,000. In a 400vh pinned section keep the text intact until 12% progress, then let the wind reach particles from left to right until 90% (each particle is delayed by its x position × 0.45). Give each particle its own drift to the upper right (34–61% of the canvas width), scatter angle and lift, and make its position a pure function of progress. Particles accelerate with an ease-in, fade out and shrink to 40% of their size, and about one in ten is accent-coloured. Use a seeded pseudo-random generator so that scrolling back returns every particle along the same path to the text. Keep the real text visually hidden for screen readers, and with reduced motion stop on the intact text. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Make particle positions a pure function of progress; integrating over time cannot return to the text on scroll-back
- Seed the random generator so the particles are the same every time
- Sample pixels after the webfont has loaded, or you will shatter the wrong glyph shapes
- Keep the real text for screen readers, and stop on intact text with reduced motion

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

---

# Text gather
Text gather — Particles assemble into text

Make scattered particles swirl together and settle into a one-line heading. Draw the heading in bold on an off-screen canvas, read its pixels, and give each particle a destination every 2px, widening the step if there would be more than 9,000. Scatter each particle’s start point on a large ring around the canvas centre (27–65% of its width). In a 400vh pinned section gather them from 6% to 70% progress and then hold the text. Give each particle a start delay of 0–0.4, a swirl direction and strength, and move it along an arc that bulges sideways by up to 90px from the straight line between start and destination, easing out with 1 − (1 − t)³ and returning to zero bulge on arrival. Draw particles as small circles whose radius grows as they arrive so that neighbours merge into solid letters. Make position a pure function of progress so scrolling back unravels the text into particles. Keep the real text visually hidden for screen readers, and with reduced motion stop on the assembled text. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Make particle positions a pure function of progress so scrolling back scatters them along the same paths
- Fix each particle’s delay and swirl from a seeded random so the assembly is identical every time
- Grow the radius on arrival so neighbours merge and the letters read as solid shapes
- Keep the real text for screen readers, and stop on the assembled text with reduced motion

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

---

# Character wave
Character wave — Character wave on scroll

Make a wave pass through a one-line heading and a small English line below it as the page scrolls. Split each line into inline-block characters and, from progress p in a 360vh pinned section, set character i’s vertical offset to translateY(−sin(p × 2 × 2π − i/n × 2π × 1.1) × 0.32em × sin(πp)); the wave passes through twice and the amplitude is squeezed to zero at both ends of the progress. Scale crest characters up to 1.18× and turn only characters whose sine exceeds 0.8 accent-coloured. Run the English line’s wave in the opposite direction. Do not wrap, and size the type with clamp from the viewport width. Keep the real text in aria-label, and with reduced motion leave the characters in a straight line. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Squeeze the amplitude to zero at both ends of progress so resting text is a straight line
- Make positions a pure function of progress so the wave rewinds with the scroll
- Never wrap the line; fit it at 375px with clamp
- Keep the real text in aria-label and hide the split characters from screen readers

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

---

# Text on a path
Text on a path — Text on a path

Make a sentence flow along a winding path as the page scrolls. In an SVG with a 1200 × 560 viewBox, draw a dotted S-shaped path that runs past both side edges, and set one bold sentence (64px) on it with textPath. In a 360vh pinned section set startOffset to "path length − p × (path length + text length)" so the head of the sentence enters at the path’s end and the tail leaves at its start. Colour one word of the sentence with the accent. The text length depends on the font, so measure it with getComputedTextLength after the font has loaded. Keep the real sentence in the SVG’s aria-label, and with reduced motion stop with the whole sentence resting on the path. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Drive startOffset from progress only; p = 0 is the sentence head at the path end
- Measure the text length after the webfont loads, or the exit point drifts
- Run the path past both screen edges so the sentence is never visibly cut mid-curve
- Keep the real text in aria-label, and rest with the whole sentence on the path under reduced motion

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

---

# Vertical writing
Vertical writing — Vertical writing reveal

Write a short vertical Japanese poem onto manuscript paper one character at a time. Put three lines in a writing-mode: vertical-rl box, read from the rightmost, each nine cells tall. Size a cell with clamp(30px, 6.2vw, 56px), draw the grid by stacking two linear-gradients at the cell length, and set each character at 72% of the cell. In a 380vh pinned section, over 0–85% progress, reveal one character at a time, top to bottom in the right-hand line and then the next line to the left. A revealing character starts 0.35em above, blurred by 4px and transparent, and settles over 0.5 seconds; only the most recently written character is accent-coloured. Show the written count below, such as "17 / 25". Keep the real text in each line’s aria-label, and with reduced motion show every character already written. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- In a vertical writing box flex row runs top to bottom, so stack the lines as blocks to get right-to-left columns
- Use one --cell for both grid lines and character advance, and no padding on the box
- Characters appear in reading order and disappear from the end on scroll-back
- Keep the real text in aria-label, and show every character with reduced motion

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

---

# Letters scatter
Letters scatter — Scattered letters snap into place

Make letters scattered in every direction spin back and line up in a row. Split a Japanese heading and a small English line into inline-block characters, and give each character a fixed seeded scattered pose (±42vw horizontally, ±42vh vertically, ±260° rotation, ±120° Y-rotation, scale 0.5–2.7) and a return delay of 0–0.35. In a 360vh pinned section compute a per-character time t = clamp((p / 0.8 − delay) / 0.65) and interpolate from the scattered pose to the aligned one with an ease-out, 1 − (1 − t)⁴. Prefix each character’s transform with perspective(900px) for depth, raise opacity from 0.15 to 1 with t, and keep scattered characters accent-coloured so that only settled ones take their final colour. Clip the stage with overflow: clip so that characters flung sideways never produce a horizontal scrollbar. Keep the real text in aria-label, and with reduced motion show the aligned state. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Fix each scattered pose from a seeded random so scrolling back scatters to the same places
- Clip the stage with overflow: clip so flung characters never create a horizontal scrollbar
- Settled characters end at exactly zero transform; none stays offset
- Keep the real text in aria-label, and stop on the aligned state with reduced motion

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

---

# Stretch a stroke
Stretch a stroke — Stretch one stroke of a title

In the heading "一部を伸ばす", stretch only the 一 stroke far out to the side. Build the 一 not as a glyph but as an accent-coloured bar 0.09em tall and 1em wide, keep its right end fixed against the remaining characters, and stretch it leftward with scaleX using transform-origin: right center. In a 320vh pinned section, from 10% to 80% progress, move the scale linearly from 1 to the factor at which the bar’s right end reaches the left edge of the viewport, measured as the bar’s right position ÷ 1em and re-measured on resize. Use only scaleX, never width, so the remaining text does not move. Add a small "STRETCH ONE STROKE" caption below. Keep the real text in aria-label, and with reduced motion stop at a stretch of 6×. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Stretch with scaleX only, never width, so the remaining text does not shift
- Anchor the stretch at the bar’s right end so it grows left without touching the text
- Measure the target scale from the viewport and re-measure on resize; it must not overflow at 375px
- Clip the stage with overflow: clip to avoid a horizontal scrollbar

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

---

# Gradient sweep
Gradient sweep — Gradient text sweep

Make a band of colour slide through the letters of three large headings. Use background-clip: text with -webkit-text-fill-color: transparent, and lay a 100deg gradient (ink → accent → light accent → accent → ink) at 280% width behind each heading. Mark each row data-progress="through" and move the band with the horizontal background-position: rows one and three move right and row two moves left, from 0% to 100% as progress goes 0 → 1. Build the colours only from var() and color-mix(); nothing spills outside the glyphs. With reduced motion stop at the centre position. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Drive the band position from progress alone, through background-position built from --p
- Never colour outside the glyphs; pair background-clip: text with a transparent fill
- Build colours only from variables and color-mix(), with no literals
- With reduced motion rest at the centre position

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

---

# Erase a part
Erase a part — Partially erased letters

Make a heading’s letters vanish one by one from the bottom up. Split the heading into one element per letter and apply clip-path: inset(0 0 calc(var(--e) * 0.62em) 0) to each glyph, erasing from the bottom up to 0.62em (about half the letter). --e runs from 0 to 1; letter i starts at 10% + i/n × 45% progress and takes 30% progress to finish, so the letters go left to right with a stagger. Mark the cut edge with a 2px accent line moved by transform: translateY(calc(var(--e) * -0.62em)) so it follows the cut. Use a 340vh pinned section and 0.18em between letters. Keep the real text in aria-label, and with reduced motion fix --e at 0.7. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Erase with clip-path only; do not hide by recolouring to the ground, which shows when the palette changes
- Move the cut-edge line with transform, never by rewriting top
- Each letter’s --e depends on progress alone, so scrolling back restores it
- Keep the real text in aria-label, and fix --e at 0.7 for reduced motion

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

---

# Sparkle scatter
Sparkle scatter — Sparkle ornaments around a title

Scatter four-point star ornaments around a heading, one after another. Draw each star as an 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) and place twelve of them outside the heading’s box at 16–40px, alternating accent and ink colours. Star k appears when progress ÷ 0.9 in a 340vh pinned section passes a value from 0.08 up to 0.88, in order, then over the next 0.14 its scale rises from 0 to 1 with easeOutBack (1 + 2.7(t−1)³ + 1.7(t−1)²) and its opacity from 0 to 1. Each star rotates 90° as it appears and keeps turning slowly with the scroll. Never overlap the lettering. Keep the heading as real text and mark the ornaments aria-hidden. With reduced motion show every star already in place. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Place stars only outside the heading; never overlap the lettering
- Timing, size and rotation depend on progress alone, so scrolling back removes them in order
- Mark ornaments aria-hidden and keep the heading as real text
- With reduced motion show every star in place

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

---

# Diagonal slashes
Diagonal slashes — Diagonal slashes through a title

Draw diagonal slashes between the letters of a heading, left to right. Set the heading in bold with 0.2em letter-spacing and overlay an SVG (its viewBox set to the heading’s real pixel size and re-read on resize) holding seven slashes that run from upper right to lower left and are spread evenly across the heading’s width. Stroke each line with var(--accent), 3px wide, with pathLength="1". In a 340vh pinned section start at 10% progress, delay each line by a further 10%, and draw each over 14% progress from top to bottom with stroke-dashoffset 1 → 0. Keep unstarted lines visibility: hidden. Drawn lines stay, and scrolling back removes them in reverse order. Keep the real heading in aria-label, and with reduced motion show all seven lines drawn. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Draw each line top to bottom, left to right; the next may start just before the previous finishes
- Match SVG coordinates to the heading’s real pixel size; stretched coordinates distort stroke width and dash length
- Keep unstarted lines visibility: hidden, since a zero-length line still leaves a round-cap dot
- Keep the real heading in aria-label, and rest with every line drawn under reduced motion

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

---

# One letter bigger
One letter bigger — One oversized letter in a title

Grow only the 大 of the heading "ひと文字だけ大きく". Split the heading into inline-block characters, colour only 大 with the accent, and scale it from 1× to 2.4× with an ease-in-out, t²(3 − 2t), using transform-origin 50% 100%. Never animate width: shift each left neighbour by translateX(−(2.4 − 1) ÷ 2 × e em) and each right neighbour by the same amount in the other direction, where e runs from 0 to 1. Use 12% to 72% of a 340vh pinned section; the bottoms stay aligned so the big letter grows upward, so reserve 1.4em above the heading. Keep the real heading in aria-label, and with reduced motion stop at the full 2.4× state. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Never animate width; neighbours step aside with translateX only
- Anchor the scale at the bottom centre so baselines stay aligned
- Reserve headroom for the growing letter so it never overlaps what is above
- Keep the real heading in aria-label, and stop on the grown state under reduced motion

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

---

# Ink bleed
Ink bleed — Ink bleed text reveal

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

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

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

---

# English underneath
English underneath — English subtitle under a title

Under a large Japanese heading, draw a rule and bring in an English subtitle. Put a 3px rule of the heading’s width directly beneath it and draw it with transform: scaleX(0 → 1) from the left edge. Set the English below in italic serif (Georgia), tightening its letter-spacing from 0.6em to 0.14em while its opacity goes from 0 to 1. In a 340vh pinned section run the rule from 12% to 42% progress and the English from 30% to 75%, both with ease-out 1 − (1 − t)³. Keep the English supporting, smaller than the heading and in var(--muted). Never wrap, and size with clamp so it fits at 375px. With reduced motion stop with the rule drawn and the English shown. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Draw the rule with scaleX, never by animating width
- Keep the English smaller and quieter than the heading so it never leads
- Never wrap; fit at 375px with clamp
- Stop with the rule and the English shown under reduced motion

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

---

# Linked letters
Linked letters — Connected letters

Link the letters of a heading with a single ribbon. Centre a bold 150px heading in an SVG with a 1200 × 300 viewBox and read each character’s extent with getExtentOfChar. Create points alternating between 62% and 32% of the letter height, add end points extended 90px beyond the first and last, and join them smoothly with cubic Béziers whose tangents are horizontal. Paint the ribbon behind the text, 26px wide in var(--accent-mid) with round caps and pathLength="1", and draw it from 10% to 80% of a 340vh pinned section by moving stroke-dashoffset from 1 to 0 with an ease-out, 1 − (1 − t)². Measure the character positions after the webfont has loaded. Keep the real heading in the SVG’s aria-label, and with reduced motion show the ribbon already drawn. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- Paint the ribbon behind the letters so legibility never drops
- Measure character positions after the webfont has loaded
- Use one continuous path, not one line per letter
- Keep the real heading in aria-label, and rest with the ribbon drawn under reduced motion

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

Spec

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

---

# Text shatter
https://design-vault.pages.dev/ai/assets/motion-text-shatter.json

---

# Text gather
https://design-vault.pages.dev/ai/assets/motion-text-gather.json

---

# Character wave
https://design-vault.pages.dev/ai/assets/motion-char-wave.json

---

# Text on a path
https://design-vault.pages.dev/ai/assets/motion-text-on-path.json

---

# Vertical writing
https://design-vault.pages.dev/ai/assets/motion-vertical-write.json

---

# Letters scatter
https://design-vault.pages.dev/ai/assets/motion-letters-scatter.json

---

# Stretch a stroke
https://design-vault.pages.dev/ai/assets/motion-stretch-stroke.json

---

# Gradient sweep
https://design-vault.pages.dev/ai/assets/motion-gradient-sweep.json

---

# Erase a part
https://design-vault.pages.dev/ai/assets/motion-erase-part.json

---

# Sparkle scatter
https://design-vault.pages.dev/ai/assets/motion-sparkle-scatter.json

---

# Diagonal slashes
https://design-vault.pages.dev/ai/assets/motion-slash-strike.json

---

# One letter bigger
https://design-vault.pages.dev/ai/assets/motion-one-letter-big.json

---

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

---

# English underneath
https://design-vault.pages.dev/ai/assets/motion-english-underline.json

---

# Linked letters
https://design-vault.pages.dev/ai/assets/motion-linked-letters.json

Files

01 motion-stroke-order/demo.html

11.6 KB

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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

Show contents
<!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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

15 motion-english-underline/demo.html

8.7 KB

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

Show contents
<!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

Show contents
<!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>