デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カードの画像が、横に細く分かれたストライプごとに、上から順に開いていきます。どのストライプも中央の線から上下へ広がるので、ブラインドの羽根が次々に返るように見えます。上へスクロールして戻せば閉じるので、何度でも見られます。説明の文は、ストライプが開ききるころに下から現れます。
元の画像を切り分けているわけではありません。高さ64px前後のストライプごとに同じ画像のコピーを入れ、画像の要素と同じ大きさのまま、ストライプの位置の分だけ上へずらして置いています。だから開ききると、継ぎ目なく1枚の写真につながります。ストライプの位置は要素に対する割合、コピーの高さは要素の高さ(100cqh)で決めているので、画面の幅が変わってもストライプを作り直さずに済みます。
開き方は clip-path の inset で、上下から半分ずつ切り落とした線の状態から広げていきます。0.7秒の power3.inOut を、0.06秒ずつずらして続けます。
つまずくのは継ぎ目です。境目がピクセルの半端な位置に来ると、背景色が細い線になって透けます。各ストライプを下へ1px重ねてこれを消し、開ききったら clip-path: none に戻して切り抜きそのものを外すことにしました。切り抜きを残したままだと、縁がにじんで画像の上端に背景色の線が見えるからです。
元の画像は透明にして残してあり、代替テキストはそのままスクリーンリーダーに届きます。ストライプを細くすると羽根が増えて細やかに、ずらす時間を延ばすと上から下へ波が伝わるように見えます。
コード
<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
);
});
}