ナビゲーション

トップへ戻るコントロール

スクロール量に応じた表示切替と、戻る操作の成功フィードバックを確認するレシピです。

動きを試す

プレビュー

プレビュー設定

プレビューを準備しています。

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • 実サービス本文と戻る閾値を調整する

<!doctype html>
<html lang="ja" data-theme="light">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>トップへ戻る</title>
    <style>
      :root { --bg:#f7f8fa; --surface:#ffffff; --text:#18212f; --muted:#5d6b7a; --border:#d7dee8; --focus:#0077cc; --accent:#245fd6; }
      :root[data-theme="dark"] { --bg:#0f141a; --surface:#171d26; --text:#f2f5f8; --muted:#aab4c0; --border:#354152; --focus:#65c7ff; --accent:#7ea7ff; }
      * { box-sizing: border-box; }
      body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: Arial, sans-serif; }
      .frame { block-size: min(620px, 100vh); overflow-y: auto; padding-block-end: 140px; }
      header, section {
        max-width: 760px;
        margin: 0 auto;
      }
      header { padding: 16px 18px 6px; }
      .body {
        max-width: 760px;
        margin: 0 auto;
        border: 1px solid var(--border);
        border-radius: 12px;
        background: var(--surface);
        padding: 12px;
      }
      .placeholder {
        margin: 10px 0;
        min-height: 520px;
        border: 1px dashed var(--border);
        border-radius: 10px;
        padding: 12px;
      }
      .go-top {
        position: fixed;
        right: 18px;
        bottom: 18px;
        min-height: 44px;
        border-radius: 999px;
        border: 1px solid var(--border);
        background: var(--surface);
        padding: 10px 14px;
      }
      .go-top[data-visible="false"] { display: none; }
      button:focus-visible { outline: 3px solid var(--focus); }
      .status { min-height: 1.2em; margin-top: 8px; }
      [data-state-panel][hidden] { display: none; }
      @media (max-width: 360px) { body { padding: 10px; } .frame { padding-bottom: 160px; } }
      @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } }
    </style>
  </head>
  <body>
    <div class="frame" id="frame">
      <header>
        <h1>トップへ戻るコントロール</h1>
      </header>
      <main class="body">
        <div data-state-panel="hidden">
          <p>最上部付近では「トップへ戻る」は非表示です。</p>
        </div>
        <div data-state-panel="visible" hidden>
          <p>十分に下までスクロールしたとみなしています。</p>
        </div>
        <div data-state-panel="activated" hidden>
          <p>戻る操作を受け付け、上部へ移動できました。</p>
        </div>
        <div class="placeholder">長文エリア(ダミー)</div>
        <div class="status" role="status" data-status></div>
      </main>
    </div>
    <button class="go-top" type="button" data-go-top>上へ戻る</button>

    <script>
      (() => {
        const params = new URLSearchParams(window.location.search);
        const instance = params.get('instance') ?? 'missing-instance';
        const statePanels = Array.from(document.querySelectorAll('[data-state-panel]'));
        const status = document.querySelector('[data-status]');
        const button = document.querySelector('[data-go-top]');
        const frame = document.querySelector('#frame');
        const validStates = new Set(['hidden', 'visible', 'activated']);

        const setTheme = (theme) => {
          if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
        };

        const setState = (nextState) => {
          if (!validStates.has(nextState)) return;
          document.documentElement.dataset.state = nextState;
          for (const panel of statePanels) panel.hidden = panel.dataset.statePanel !== nextState;
          if (button) button.dataset.visible = String(nextState !== 'hidden');
          if (status) status.textContent = `状態: ${nextState}`;
        };

        const updateFromScroll = () => setState(frame.scrollTop > 200 ? 'visible' : 'hidden');
        let activatedByControl = false;
        frame.addEventListener('scroll', () => {
          if (activatedByControl && frame.scrollTop === 0) {
            activatedByControl = false;
            setState('activated');
            return;
          }
          updateFromScroll();
        }, { passive: true });
        button.addEventListener('click', () => {
          activatedByControl = true;
          frame.scrollTo({
            top: 0,
            behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
          });
        });

        window.addEventListener('message', (event) => {
          if (event.source !== window.parent || typeof event.data !== 'object' || event.data === null) return;
          if (event.data.source !== 'wpl-host' || event.data.instance !== instance) return;
          if (event.data.type === 'wpl-preview-state') setState(event.data.value);
          if (event.data.type === 'wpl-preview-theme') setTheme(event.data.value);
        });

        setTheme(params.get('theme') === 'dark' ? 'dark' : 'light');
        setState(validStates.has(params.get('state')) ? (params.get('state') ?? 'hidden') : 'hidden');
        window.parent.postMessage({ source: 'wpl-preview', type: 'wpl-preview-ready', instance }, '*');
      })();
    </script>
  </body>
</html>
プレビューで試せる動き
  • 非表示: 上部近辺では非表示で邪魔にならない。
  • 表示: 下位へスクロールした時のみ表示される。
  • 戻った: クリック後に上部へ戻り、状態表示を更新する。

使い分けを確認する

このレシピが合う場面

適する場面

  • 長文ページでトップ移動を短い操作で行う
  • 読み進め状況を壊さずに戻る導線を提供する
  • マウスだけでなくキーボードでも戻る操作を確認する

適さない場面

  • ページ内に十分な高さがない短文レイアウト
  • スクロール位置監視を重くしたくない重いSPAの都合