デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画像を横の帯(高さ 64px 前後)に分け、帯ごとに中央の線から上下へ開いて見せます。どの帯にも同じ画像の写しを入れ、枠と同じ大きさのまま、帯の位置の分だけ上へずらして置くので、開ききると継ぎ目のない 1 枚に見えます。帯の位置は枠に対する割合で決め、写しの高さは枠の高さ(100cqh)にしてあるので、画面の幅が変わっても帯を作り直さずに済みます。
開き方は clip-path の inset で、上下から 50% ずつ切った線の状態から、0.7 秒かけて 0% まで広げます(power3.inOut)。上の帯から 0.06 秒ずつずらして続けると、ブラインドの羽が順にめくれるように見えます。枠の上端が画面の高さの 85% を越えたら開き、上へ戻して線より下へ外れたら閉じるので、何度でも見られます。説明の文は、帯が開ききるころに下から出します。
帯の境目が半端な位置に来ると、境目に地の色が細く透けます。帯を下へ 1px 重ねておくと消えます。開ききったあとも切り抜きを残していると、枠が半端な位置にあるときに縁がにじみ、画像の上端に地の色の線が出るので、開ききったら clip-path: none に戻します。元の画像は透明にして枠に残すので、代替テキストは読み上げに届きます。
帯を細くするほど羽が増えて細やかになり、ずらす時間を長くするほど、上から下へ波が伝わるように見えます。1 回だけ開けばよいなら、ScrollTrigger に once を付けます。動きを減らす設定では帯に分けず、画像をそのまま見せます。記事や事例紹介、旅行のページのように、読み進めるたびに写真が現れる縦長のページに向いています。
コード
<article class="journal" aria-labelledby="journal-title">
<div class="journal__intro">
<div>
<p class="journal__label">Journal — No.12</p>
<h2 class="journal__title" id="journal-title">海辺の町をめぐる</h2>
</div>
<p class="journal__lead">ここに説明文が入ります。</p>
</div>
<!-- .blinds の画像を、JS が横の帯に分けて開く。--ratio は枠の縦横比 -->
<figure class="blinds" style="--ratio: 21 / 9">
<div class="blinds__frame">
<img src="/images/harbour-dawn.jpg" alt="夜明けの港。淡い紅色の空の下、静かな海に小さな帆船が浮かぶ" width="2100" height="900">
</div>
<figcaption class="blinds__caption"><span>01 — 夜明けの港</span> <span>午前5時42分</span></figcaption>
</figure>
<div class="journal__row">
<section class="journal__story">
<p class="journal__label">Chapter 01</p>
<h3 class="journal__heading">潮の引いた<br>夜明けの港</h3>
<p>ここに説明文が入ります。</p>
</section>
<figure class="blinds" style="--ratio: 4 / 5">
<div class="blinds__frame">
<img src="/images/keepers-light.jpg" alt="青空の下、緑の岬に立つ紅白の縞の灯台" width="864" height="1080">
</div>
<figcaption class="blinds__caption"><span>02 — 岬の灯台</span> <span>午前7時10分</span></figcaption>
</figure>
</div>
<div class="journal__row">
<figure class="blinds" style="--ratio: 4 / 3">
<div class="blinds__frame">
<img src="/images/dunes.jpg" alt="朝の日を受けて、淡い砂色に重なる砂丘の稜線" width="1024" height="768">
</div>
<figcaption class="blinds__caption"><span>03 — 砂丘のはじまり</span> <span>午前9時25分</span></figcaption>
</figure>
<section class="journal__story">
<p class="journal__label">Chapter 02</p>
<h3 class="journal__heading">道が砂に<br>変わる場所</h3>
<p>ここに説明文が入ります。</p>
</section>
</div>
</article>
.journal {
--bg: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b;
--holder: rgb(38 38 38 / 0.12); /* 開く前の枠の地 */
--strip: 64px; /* 帯の高さの目安。JS が枠の高さから、この前後になる本数を決める */
/* 中身の幅は 1088px まで。狭い画面では左右に余白をとる */
padding: 96px max(clamp(16px, 7.5vw, 96px), (100% - 1088px) / 2) 144px;
background: var(--bg);
color: var(--ink);
}
.journal__intro {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: last baseline;
gap: 24px 48px;
margin-bottom: 48px;
}
.journal__label {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
}
.journal__title {
margin: 16px 0 0;
font-size: clamp(32px, 5.5vw, 64px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
word-break: auto-phrase;
}
.journal__lead,
.journal__story p:last-child { margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
/* 文と画像を左右に振る。1 つめの段は文が左、2 つめの段は画像が左 */
.journal__row { display: flex; justify-content: space-between; align-items: center; gap: 48px; margin-top: 96px; }
.journal__story,
.journal__row .blinds { flex: 0 1 432px; }
.journal__row .blinds:first-child { flex-basis: 512px; }
.journal__heading { margin: 16px 0 24px; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.blinds { margin: 0; }
/* 枠。帯の中の写しは、この枠の高さ(100cqh)で敷く */
.blinds__frame {
position: relative;
overflow: hidden;
aspect-ratio: var(--ratio);
border-radius: 4px;
background: var(--holder);
container-type: size;
}
.blinds__frame img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* 帯に分けたあとは、元の画像を透明にして残す(alt は読み上げに届く) */
.blinds__frame.is-split > img { opacity: 0; }
/* 帯は枠に対する % で並べ、境目に地の色が透けないよう下へ 1px 重ねる */
.blinds__strip {
position: absolute;
left: 0;
right: 0;
top: calc(100% * var(--i) / var(--count));
height: calc(100% / var(--count) + 1px);
overflow: hidden;
}
/* 帯の中の写しは枠と同じ大きさにし、帯の位置の分だけ上へずらす。並べると継ぎ目のない 1 枚に見える */
.blinds__strip img { position: absolute; left: 0; top: calc(-100cqh * var(--i) / var(--count)); height: 100cqh; }
.blinds__caption {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
font-size: 14px;
line-height: 1.4;
letter-spacing: 0.04em;
color: var(--mute);
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px) {
.journal__row { display: grid; gap: 32px; margin-top: 64px; }
.journal__lead,
.journal__story p:last-child { font-size: 16px; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const EACH = 0.06; // 隣の帯とのずれ(秒)
const OPEN = 0.7; // 1 本の帯が開く時間(秒)
// 枠の中に、画像の写しを入れた横の帯を並べる。位置は CSS が --i と --count から決める
function split(figure) {
const frame = figure.querySelector(".blinds__frame");
const img = frame.querySelector("img");
const strip = parseFloat(getComputedStyle(frame).getPropertyValue("--strip"));
const count = Math.max(3, Math.round(frame.offsetHeight / strip));
const box = document.createElement("div");
box.setAttribute("aria-hidden", "true"); // 写しは読み上げない(元の画像の alt が読まれる)
for (let i = 0; i < count; i++) {
const band = document.createElement("div");
band.className = "blinds__strip";
band.style.setProperty("--i", i);
const copy = img.cloneNode();
copy.alt = "";
band.append(copy);
box.append(band);
}
frame.style.setProperty("--count", count);
frame.append(box);
frame.classList.add("is-split");
return [...box.children];
}
// 動きを減らす設定では帯に分けず、画像をそのまま見せる
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
document.querySelectorAll(".blinds").forEach((figure) => {
const strips = split(figure);
gsap
.timeline({
scrollTrigger: {
trigger: figure,
start: "top 85%", // 枠の上端が画面の高さの 85% を越えたら開く
toggleActions: "play none none reverse", // 上へ戻して線より下へ外れたら閉じ、また開けるようにする
},
})
// 帯ごとに中央の線から上下へ開き、上の帯から順に続ける
.fromTo(
strips,
{ clipPath: "inset(50% 0% 50% 0%)" },
{ clipPath: "inset(0% 0% 0% 0%)", duration: OPEN, ease: "power3.inOut", stagger: EACH }
)
// 開ききったら切り抜きを外す。枠が半端な位置にあると、切り抜きの縁がにじんで、画像の縁に地の色の細い線が出る
.set(strips, { clipPath: "none" })
// 開ききるころに説明を出す。読み上げには初めから届くよう、opacity だけで隠す
.fromTo(
figure.querySelector(".blinds__caption"),
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.6, ease: "power2.out" },
OPEN * 0.5 + EACH * strips.length * 0.5
);
});
}