"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 画面端の縦組み常設CTA / Vertical Edge CTA Rail
* 既存のCTAはどれも本文の流れに置く「節」で、読み進めて到達したときにだけ現れる。
* これは本文の外=画面の右端に、読んでいる間ずっと居座る常設の導線にした。
* さらに導線を計器と兼ねさせ(読了率・現在の節・節ごとの目盛り)、
* 本文末のCTAの節に届いたら自分から退いて役目を引き継ぐ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 常設CTAは「出しっぱなし」にしない: 出現(読み始めの閾値)と退出(本文末のCTAへ到達)の
* 2つの条件をセットで設計する。末尾で消さないと、同じ用途のCTAが画面に2つ並んだまま
* 最後の一押しが割れる。退出は本文側のCTAを IntersectionObserver で見張るだけでよく、
* スクロール量の閾値を二重に持つ必要はない(本文が伸び縮みしても勝手に追随する)。
* - 端の導線は「幅」ではなく「縦組み」で成立させる: [writing-mode:vertical-rl] の和文は
* 1行の幅が字送りぶんしか要らないので、44〜56px の帯に読める大きさ(15〜16px)の
* 本文級を置ける。横組みのまま縮めると10px級の小さな字になり、常設なのに読めない帯になる。
* 和文かなは vertical-rl の既定(text-orientation: mixed)でそのまま正立するので指定は要らない。
* - 常設の帯は計器を兼ねさせると居座る理由ができる: 読了率のバー・節の目盛り・現在の節名を
* 同じ帯に積むと、「押す気が無い間も見る価値がある」ので視界の隅にあっても邪魔にならない。
* 目盛りの位置は等間隔に置かず、各節が実際にアクティブになる進捗値
* ((offsetTop − 判定線) ÷ 最大スクロール量)から出す。等間隔は嘘の位置になる。
* - 判定線と目盛りの計算式を1つに揃える: 現在の節の判定を IntersectionObserver の
* rootMargin(例: "-40% 0px -55% 0px" =画面の上から40%の位置に判定線)で行うなら、
* 目盛りの位置計算にも同じ 0.4 を使う。別々に決めると、バーが目盛りに届く瞬間と
* 節名が切り替わる瞬間がずれて、指標として信用されなくなる。
* - 帯のぶんの余白を本文側に確保する: 常設の帯は本文の上に重なるので、本文コンテナへ
* padding-right を帯の幅ぶん(+わずかな逃げ)入れる。これを省くと、狭い画面で行末の
* 数文字が帯の下へ潜り、読めるのに読めない行ができる。帯を半透明にしても解決しない。
* - 縦位置は画面幅で変える: 広い画面は視線の高さ=上下中央、狭い画面は親指の届く右下
* (bottom-6)へ寄せる。狭い画面では計器を省いて帯自体も短くし、本文の可読幅を守る。
* - 画面外へ退けた要素はフォーカスも退ける: translate-x で隠しただけの要素は見えないまま
* タブ順に残る。React 19 の inert を退出中だけ立てると、モーションを保ったまま到達だけ止まる。
* - 動かすのは transform と opacity だけ: 出入りは translate-x、読了バーは origin-top の scaleY、
* ホバーは origin-bottom の scaleY で敷く面。幅・高さ・padding を動かすと本文がリフローする。
* 読了バーはスクロールに追随する指標なので transition を持たせない(遅れて追う棒は誤差に見える)。
* - 角丸・英字キッカー・末尾の「→」を使わない: 端の帯は画面の縁と接するので、角を丸めると
* 縁から浮いて「閉じ忘れた小窓」に見える。直線と hairline だけで縁に貼り付ける。
* - 掲載している本文・受付時間はすべて架空の表示サンプル。
*
* 実案件への移植:
* ギャラリー内で常設の挙動を見せるため「デモシェル(自前のスクロールコンテナ)+ダミー本文」を
* 同梱している。実サイトでは <aside> だけを取り出して
* 1. absolute → fixed(top-1/2 right-0 z-50)にする
* 2. スクロール監視を shell.scrollTop / shell.scrollHeight → window.scrollY /
* document.documentElement.scrollHeight に差し替える
* 3. IntersectionObserver の root: shell を外す(既定のビューポートにする)
* の3点を変えるだけでよい。
*/
type Section = {
id: string;
label: string;
heading: string;
body: string[];
};
/** ダミー本文(表示サンプル)。実案件では実際のページの節に置き換える。 */
const SECTIONS: readonly Section[] = [
{
id: "intro",
label: "はじめに",
heading: "読みながら、いつでも相談できる状態にしておく",
body: [
"問い合わせの導線をページの末尾にだけ置くと、読み終える前に気持ちが固まった人の行き先がなくなります。本文の外側に細い帯を1本立てておくと、どの段落を読んでいる途中でも一手で相談へ移れます。",
"帯は本文の幅を削りません。画面の縁に貼り付けて、読み手の視界の端に置いておくだけです。",
],
},
{
id: "issue",
label: "課題の整理",
heading: "常設の導線が嫌われるのは、居座る理由がないとき",
body: [
"画面に貼り付いたまま何も語らない帯は、ただの障害物として扱われます。読了率・いま読んでいる節・節の目盛りのように、押す気がない間にも役に立つ情報を同じ帯に積むと、視界の隅にあっても納得のある存在になります。",
"帯の役目は二つあります。相談への入口であること、そして本文のどこにいるかを示す計器であることです。",
],
},
{
id: "flow",
label: "進め方",
heading: "出現と退出を対で決める",
body: [
"読み始めた合図(一定量のスクロール)で滑り込ませ、本文末の問い合わせの節に届いたら退かせます。同じ用途の導線が画面に二つ並ぶと、最後の一押しがどちらに向くか割れてしまうためです。",
"退出の判定は、本文側の問い合わせブロックが画面に入ったかどうかだけを見ます。本文が伸び縮みしても、閾値を測り直す必要はありません。",
],
},
{
id: "cost",
label: "費用の考え方",
heading: "追加の実装量はごく小さい",
body: [
"必要なのは、スクロール量の監視ひとつと、現在の節・退出の判定に使う交差監視が二つだけです。画像素材は使いません。",
"帯の幅は、和文を縦に組むことで四十数ピクセルに収まります。横に組んだまま縮めると本文級の大きさを保てません。",
],
},
{
id: "faq",
label: "よくある質問",
heading: "スマートフォンでも同じ考え方で置ける",
body: [
"狭い画面では計器の部分を省き、親指の届く右下へ寄せます。縦組みのまま帯を短くすれば、本文の可読幅を削らずに常設の導線を残せます。",
"動きを減らす設定の環境では、滑り込みは行わず即座に表示へ切り替わります。",
],
},
];
/** IntersectionObserver の判定線(画面の上から40%)。目盛りの位置計算にも同じ値を使う。 */
const ACTIVE_LINE = 0.4;
export default function StickyEdgeCta() {
const shellRef = useRef<HTMLDivElement>(null);
const handoffRef = useRef<HTMLDivElement>(null);
const [progress, setProgress] = useState(0);
const [started, setStarted] = useState(false);
const [atHandoff, setAtHandoff] = useState(false);
const [active, setActive] = useState(0);
/** 各節がアクティブになる進捗値 0〜1(目盛りの位置) */
const [ticks, setTicks] = useState<number[]>([]);
// デモシェルの初期位置。実サイトでは不要(読み始めの位置はユーザーが決める)。
// ギャラリーの一覧カードは静止画なので、代表的な状態=帯が出ている位置から見せる。
useEffect(() => {
const shell = shellRef.current;
if (shell) shell.scrollTop = 260;
}, []);
// スクロール監視(実案件では window.scrollY を見る)
useEffect(() => {
const shell = shellRef.current;
if (!shell) return;
const onScroll = () => {
const max = shell.scrollHeight - shell.clientHeight;
setProgress(max > 0 ? Math.min(1, Math.max(0, shell.scrollTop / max)) : 0);
setStarted(shell.scrollTop > 160);
};
onScroll();
shell.addEventListener("scroll", onScroll, { passive: true });
return () => shell.removeEventListener("scroll", onScroll);
}, []);
// 目盛りの位置を実測する(等間隔に置かない)
useEffect(() => {
const shell = shellRef.current;
if (!shell) return;
const measure = () => {
const max = shell.scrollHeight - shell.clientHeight;
if (max <= 0) {
setTicks([]);
return;
}
const line = shell.clientHeight * ACTIVE_LINE;
setTicks(
Array.from(shell.querySelectorAll<HTMLElement>("[data-section]")).map((el) =>
Math.min(1, Math.max(0, (el.offsetTop - line) / max)),
),
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
return () => ro.disconnect();
}, []);
// 現在の節(判定線は rootMargin で引く)
useEffect(() => {
const shell = shellRef.current;
if (!shell) return;
const targets = Array.from(shell.querySelectorAll<HTMLElement>("[data-section]"));
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const index = targets.indexOf(entry.target as HTMLElement);
if (index >= 0) setActive(index);
}
},
{
root: shell,
rootMargin: `-${ACTIVE_LINE * 100}% 0px -${(1 - ACTIVE_LINE) * 100 - 5}% 0px`,
},
);
targets.forEach((target) => io.observe(target));
return () => io.disconnect();
}, []);
// 本文末のCTAへ到達したら退く(役目の引き継ぎ)
useEffect(() => {
const shell = shellRef.current;
const handoff = handoffRef.current;
if (!shell || !handoff) return;
const io = new IntersectionObserver(([entry]) => setAtHandoff(entry.isIntersecting), {
root: shell,
});
io.observe(handoff);
return () => io.disconnect();
}, []);
const shown = started && !atHandoff;
return (
<div className="relative w-full max-w-3xl overflow-hidden border border-gray-200 bg-white">
{/* デモシェル: 実サイトではこのスクロールコンテナごと不要(window スクロールに読み替える) */}
<div ref={shellRef} className="relative h-[480px] overflow-y-auto sm:h-[560px]">
{/* 本文には帯のぶんの余白を確保する(帯の下へ文字を潜らせない) */}
<article className="px-6 py-12 pr-16 sm:px-14 sm:py-16 sm:pr-20">
<p className="text-[11px] font-semibold tracking-[0.14em] text-gray-500">
表示サンプル・制作ノート
</p>
<p className="mt-4 max-w-lg text-[26px] leading-[1.45] font-bold tracking-tight text-gray-900 sm:text-[32px]">
端に立てる、一本の帯
</p>
<span aria-hidden="true" className="mt-6 block h-px w-10 bg-amber-600" />
{SECTIONS.map((section) => (
<section key={section.id} data-section id={section.id} className="mt-14 sm:mt-20">
<div className="flex items-baseline gap-3">
<span aria-hidden="true" className="h-px w-5 shrink-0 bg-amber-600" />
<span className="text-[11px] font-semibold tracking-[0.14em] text-gray-500">
{section.label}
</span>
</div>
<h3 className="mt-3 max-w-lg text-[17px] leading-[1.6] font-bold text-gray-900 sm:text-[19px]">
{section.heading}
</h3>
{section.body.map((paragraph, index) => (
<p
key={index}
className="mt-4 max-w-xl text-[14px] leading-[2.05] text-gray-700 sm:text-[15px]"
>
{paragraph}
</p>
))}
</section>
))}
{/* 本文末のCTA。ここへ到達すると端の帯は退く */}
<div ref={handoffRef} className="mt-16 border-t border-gray-200 pt-10 sm:mt-24">
<div className="flex items-baseline gap-3">
<span aria-hidden="true" className="h-px w-5 shrink-0 bg-amber-600" />
<span className="text-[11px] font-semibold tracking-[0.14em] text-gray-500">
お問い合わせ
</span>
</div>
<p className="mt-3 max-w-md text-[17px] leading-[1.6] font-bold text-gray-900 sm:text-[19px]">
進め方の相談だけでも承ります
</p>
<p className="mt-3 max-w-md text-[14px] leading-[2] text-gray-700">
内容が固まっていない段階でも、現状の課題を伺ったうえで進め方をご提案します。
</p>
<a
href="#intro"
className="mt-6 inline-block bg-amber-600 px-7 py-3.5 text-[14px] font-semibold text-white transition-colors duration-200 hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700 motion-reduce:transition-none"
>
相談の内容を送る
</a>
</div>
</article>
</div>
{/* ===== ここから移植対象の常設CTA(実サイトでは absolute → fixed) =====
デモでは「スクロールしない外枠」の子として置く。スクロールコンテナの内側に
absolute で置くと、基準がスクロールする内容側になり本文と一緒に流れてしまう
(fixed がビューポートを基準にするのと同じ位置関係を、外枠で作っている)。 */}
<aside
aria-label="お問い合わせと現在地"
inert={!shown}
className={`absolute right-0 bottom-6 z-30 flex flex-col items-stretch border-y border-l border-gray-200 bg-white/95 backdrop-blur-sm transition-transform duration-500 ease-out sm:top-1/2 sm:bottom-auto sm:-translate-y-1/2 motion-reduce:transition-none ${
shown ? "translate-x-0" : "translate-x-full"
}`}
>
{/* 計器(読了率・目盛り・現在の節)。狭い画面では省く */}
<div className="hidden w-14 flex-col items-center gap-3 px-2 py-5 sm:flex">
<span
className="max-h-24 overflow-hidden text-[11px] leading-none font-semibold tracking-[0.28em] whitespace-nowrap text-gray-600 [writing-mode:vertical-rl]"
aria-hidden="true"
>
{SECTIONS[active].label}
</span>
<div className="relative h-24 w-px bg-gray-200" aria-hidden="true">
<span
style={{ transform: `scaleY(${progress})` }}
className="absolute inset-0 origin-top bg-amber-600"
/>
{ticks.map((tick, index) => (
<span
key={index}
style={{ top: `${tick * 100}%` }}
className={`absolute left-1/2 size-[3px] -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 ease-out motion-reduce:transition-none ${
index <= active ? "scale-150 bg-amber-600" : "bg-gray-300"
}`}
/>
))}
</div>
<span
className={`${montserrat.className} text-[10px] leading-none font-medium tabular-nums text-gray-400`}
aria-hidden="true"
>
{Math.round(progress * 100)}%
</span>
</div>
<span aria-hidden="true" className="hidden h-px w-full bg-gray-200 sm:block" />
{/* 主導線: 縦組みの和文をそのままタップ領域にする */}
<a
href="#intro"
className="group relative flex w-11 items-center justify-center py-6 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-700 sm:w-14 sm:py-7"
>
<span aria-hidden="true" className="absolute inset-0 bg-amber-600" />
<span
aria-hidden="true"
className="absolute inset-0 origin-bottom scale-y-0 bg-amber-700 transition-transform duration-300 ease-out group-hover:scale-y-100 group-focus-visible:scale-y-100 motion-reduce:transition-none"
/>
<span className="relative text-[15px] leading-none font-semibold tracking-[0.22em] whitespace-nowrap text-white [writing-mode:vertical-rl] sm:text-base">
お問い合わせ
</span>
</a>
<span className="sr-only">
現在の節: {SECTIONS[active].label}(読了 {Math.round(progress * 100)}
パーセント)
</span>
</aside>
</div>
);
}