Scroll-Driven Text Reveal
An editorial scroll-scrub reveal: headline lines rise out of a mask, hairlines draw themselves, and list items tilt upright as you scroll. JavaScript only writes two custom properties on the root (--p for progress, --q for parallax) from a single rAF-coalesced handler; every child derives its own local progress purely in CSS with clamp((--p - --s) / --w, 0, 1). No per-element JS animation, no per-frame setState, no dependencies. The progress denominator blends the element's own height, so the effect still completes when the block is rendered small. Only transform and opacity animate, and reduced-motion collapses to the finished state via --p: 1 !important. Only the idea (scroll-linked type and card reveal) is borrowed from the reference pen.
- Added:
- 2026-08-09
- Dependencies:
- None
- tags
- #block #scroll #reveal #editorial #typography #motion #parallax #no-image #no-dependency
Preview
BLK-17 — Scroll Reveal / スクロール駆動リビール
読み進めるほど、言葉のほうから立ち上がってくる。
スクロール量そのものを時間軸にすると、読む速さと現れる速さが一致します。First CH は動きを飾りとしてではなく、読み順の設計として実装します。
- 設計
読ませたい順に、現れる
見出し・要点・補足の順に立ち上がるよう、要素ごとに開始位置をずらします。すべてが同時に動くと、どこから読めばよいのかがかえって消えます。
- 実装
動かすのは、二つだけ
変形(transform)と不透明度(opacity)以外は動かしません。スクロールのたびにレイアウトを計算し直さないので、長いページでも動きが引っかかりません。
- 配慮
止めたい人には、止まったまま
動きを控える設定の環境では、完成した状態をそのまま表示します。演出を一度も見なくても、伝わる情報は欠けません。
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
/**
* スクロール駆動テキスト・リビール演出(BLK-17)
*
* 着想: 「スクロール量に連動して文字とカードが
* 出現・変形する」という着想のみを参照。外部コード・文言・配色・構成は一切取り込んでおらず、
* First CH ブランド(白基調+アンバー #d97706)で全面的に再構築している(2026-08-09)。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **スクロール進捗を1本のCSS変数に集約する**: スクロール中に走るJSは「rAFで合体させた
* 1つのハンドラが root へ --p(0→1)と --q(視差用)を書く」だけ。個々の要素は
* `--t: clamp(0, (var(--p) - var(--s)) / var(--w), 1)` で**自分のローカル進捗をCSSのcalcで
* 自力計算**する。要素ごとのJSアニメーションもフレームごとの setState も発生しないので、
* 要素を増やしてもコストが増えない(スタッガーは --s をインラインで渡すだけ)。
* - **進捗の測り方**: `p = (vh*0.85 - rect.top) / min(要素高, vh*0.7)`。分母に要素自身の高さを
* 混ぜるのが要点で、これがないと「ビューポートより低い要素は永久に完了しない」「巨大な要素は
* 一瞬で完了する」のどちらかに倒れる。ギャラリーの縮小プレビューのように小さく描画された
* ときも最後まで到達する。
* - **動かすのは transform と opacity だけ**: width/top/background-position を触らないので
* スクロール中にレイアウト再計算が起きない。罫線は scaleX、進捗レールは scaleY、
* 見出しは overflow-hidden の親+子の translateY(105%) というマスクリビールで表現する。
* - **reduced-motion はJSではなくCSSで畳む**: `@media (prefers-reduced-motion: reduce)` で
* `--p: 1 !important` を当てると、JSが書くインラインスタイルより強いので**完成状態が唯一の
* 状態**になる(JS側もリスナーを張らない)。「動きを止める」ではなく「最初から完成している」
* に倒すのがこの型の正解。
* - スクロール中に更新する数値表示(000→100)は setState せず ref から textContent を直接書く。
*/
type Entry = {
/** 行の性格を示す和文ミニラベル */
kicker: string;
title: string;
body: string;
};
const entries: Entry[] = [
{
kicker: "設計",
title: "読ませたい順に、現れる",
body: "見出し・要点・補足の順に立ち上がるよう、要素ごとに開始位置をずらします。すべてが同時に動くと、どこから読めばよいのかがかえって消えます。",
},
{
kicker: "実装",
title: "動かすのは、二つだけ",
body: "変形(transform)と不透明度(opacity)以外は動かしません。スクロールのたびにレイアウトを計算し直さないので、長いページでも動きが引っかかりません。",
},
{
kicker: "配慮",
title: "止めたい人には、止まったまま",
body: "動きを控える設定の環境では、完成した状態をそのまま表示します。演出を一度も見なくても、伝わる情報は欠けません。",
},
];
/** 見出し3行。--s で立ち上がる順序をずらす */
const headline = ["読み進めるほど、", "言葉のほうから", "立ち上がってくる。"];
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
/** インラインで渡すスタッガー開始位置(0〜1のスクロール進捗上の座標) */
const at = (s: number) => ({ "--s": s.toFixed(2) }) as CSSProperties;
export default function ScrollRevealEditorial() {
const rootRef = useRef<HTMLElement>(null);
const readoutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
// reduced-motion: 完成状態を CSS 側(--p: 1 !important)が固定するので、
// JS は数値表示を合わせるだけでリスナーを張らない。
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
if (readoutRef.current) readoutRef.current.textContent = "100";
return;
}
let rafId = 0;
let visible = true;
const update = () => {
rafId = 0;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || 1;
// 上端がビューポート85%を切ってから、min(要素高, 70vh) ぶん進む間で 0→1
const span = Math.max(1, Math.min(rect.height, vh * 0.7));
const p = clamp01((vh * 0.85 - rect.top) / span);
// 視差用: セクションがビューポートを通過する全体量
const q = clamp01((vh - rect.top) / (vh + rect.height));
el.style.setProperty("--p", p.toFixed(4));
el.style.setProperty("--q", q.toFixed(4));
const readout = readoutRef.current;
if (readout) {
const n = String(Math.round(p * 100)).padStart(3, "0");
if (readout.textContent !== n) readout.textContent = n;
}
};
// rAF で1フレーム1回に合体させる(scroll は passive)
const onScroll = () => {
if (!visible || rafId) return;
rafId = requestAnimationFrame(update);
};
// 画面外では計測そのものを止める(出入りの瞬間には必ず1回そろえる)
const io =
typeof IntersectionObserver !== "undefined"
? new IntersectionObserver(
(es) => {
visible = es.some((e) => e.isIntersecting);
update();
},
{ rootMargin: "30% 0px" }
)
: null;
io?.observe(el);
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
return () => {
io?.disconnect();
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<section
ref={rootRef}
aria-labelledby="blk17-heading"
className="blk17 relative w-full max-w-5xl bg-white px-1"
>
<div className="grid grid-cols-[auto_1fr] gap-5 sm:gap-8 md:gap-10">
{/* 進捗レール(スクロール量そのものを可視化する。装飾ではなく読み進みの目盛り) */}
<div className="flex flex-col items-center gap-3 pt-1">
<span
aria-hidden="true"
className="font-display text-[10px] font-semibold tracking-[0.3em] text-gray-400 uppercase [writing-mode:vertical-rl]"
>
Scroll
</span>
<span
aria-hidden="true"
className="relative w-px flex-1 overflow-hidden bg-gray-200"
>
<span className="blk17-fill absolute inset-0 block bg-amber-600" />
</span>
<span
ref={readoutRef}
aria-hidden="true"
className="font-display text-[10px] font-bold text-gray-400 tabular-nums"
>
000
</span>
</div>
{/* 本体 */}
<div className="min-w-0">
<p
style={at(0)}
className="blk17-rev blk17-fade font-display text-xs font-bold tracking-[0.25em] text-amber-600 uppercase"
>
BLK-17 — Scroll Reveal / スクロール駆動リビール
</p>
{/* 見出し: 親を overflow-hidden にして子を translateY(105%) から戻すマスクリビール */}
<h2
id="blk17-heading"
className="mt-5 max-w-[46rem] text-[clamp(1.45rem,5vw,3.25rem)] leading-[1.42] font-bold tracking-tight text-gray-900"
>
{headline.map((line, i) => (
<span key={line} className="block overflow-hidden">
<span
style={at(0.02 + i * 0.08)}
className="blk17-rev blk17-line block"
>
{line}
{i === headline.length - 1 && (
<span
aria-hidden="true"
style={at(0.26)}
className="blk17-rev blk17-mark ml-2 inline-block h-2.5 w-2.5 bg-amber-600 align-middle"
/>
)}
</span>
</span>
))}
</h2>
<p
style={at(0.2)}
className="blk17-rev blk17-fade mt-7 max-w-xl text-[14.5px] leading-[2] font-medium text-gray-600"
>
スクロール量そのものを時間軸にすると、読む速さと現れる速さが一致します。First CH は動きを飾りとしてではなく、読み順の設計として実装します。
</p>
{/* 目盛りストリップ(--q による横方向の視差。overflow-hidden の帯なので衝突しない) */}
<div
aria-hidden="true"
className="mt-10 h-4 overflow-hidden border-t border-gray-200 pt-2 md:mt-12"
>
<div className="blk17-ruler flex w-max items-start gap-3">
{Array.from({ length: 60 }, (_, i) => (
<span
key={i}
className={
i % 5 === 0 ? "h-2 w-px bg-gray-300" : "h-1 w-px bg-gray-200"
}
/>
))}
</div>
</div>
{/* 3項目: カードで囲まず、上端の hairline と余白だけでグルーピングする */}
<ul className="mt-2 grid grid-cols-1 gap-x-8 md:mt-4 md:grid-cols-3">
{entries.map((e, i) => (
<li key={e.title} className="group relative pt-6 pb-2 md:pt-7">
{/* 罫線は scaleX で「引かれる」(transform のみ) */}
<span
aria-hidden="true"
style={at(0.3 + i * 0.09)}
className="blk17-rev blk17-rule absolute inset-x-0 top-0 h-px bg-gray-300"
/>
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px origin-left scale-x-0 bg-amber-600 transition-transform duration-500 ease-out group-hover:scale-x-100 motion-reduce:transition-none"
/>
<div
style={at(0.34 + i * 0.09)}
className="blk17-rev blk17-item"
>
<span className="text-[11px] font-bold tracking-[0.18em] text-amber-700">
{e.kicker}
</span>
<h3 className="mt-2.5 text-[15px] leading-[1.7] font-bold tracking-tight text-gray-900 md:text-base">
{e.title}
</h3>
<p className="mt-3 text-[13px] leading-[2] text-gray-600">
{e.body}
</p>
</div>
</li>
))}
</ul>
<div
style={at(0.62)}
className="blk17-rev blk17-fade mt-10 border-t border-gray-200 pt-6"
>
<a
href="#"
className="group inline-flex items-center gap-2 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
制作の進め方を見る
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
<span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
</div>
</div>
{/* スクロール進捗 → ローカル進捗の換算はすべてここ(JSは --p / --q を書くだけ) */}
<style>{`
.blk17 { --p: 0; --q: 0; --w: 0.28; }
.blk17-rev { --t: clamp(0, (var(--p) - var(--s, 0)) / var(--w), 1); }
.blk17-line {
transform: translateY(calc((1 - var(--t)) * 105%));
will-change: transform;
}
.blk17-fade {
opacity: var(--t);
transform: translateY(calc((1 - var(--t)) * 18px));
will-change: transform, opacity;
}
.blk17-item {
opacity: var(--t);
transform: translateY(calc((1 - var(--t)) * 26px)) rotate(calc((1 - var(--t)) * -1.1deg));
transform-origin: left top;
will-change: transform, opacity;
}
.blk17-rule { transform: scaleX(var(--t)); transform-origin: left center; }
.blk17-mark { transform: scale(var(--t)); transform-origin: center center; }
.blk17-fill { transform: scaleY(var(--p)); transform-origin: top center; }
.blk17-ruler { transform: translateX(calc((0.5 - var(--q)) * 72px)); }
@media (prefers-reduced-motion: reduce) {
.blk17 { --p: 1 !important; --q: 0.5 !important; }
}
`}</style>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/scroll-reveal-editorial.json