本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

読み進めると「トップへ戻る」ボタンが弾んで出て、押すと先頭へ戻る

デモ

操作:スクロール・クリック・カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

動作
DOM
種類
レシピ
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

右下に止めた丸いボタンを、読み進めて画面の高さの 60% を越えたところで弾ませて出し、それより上へ戻したら引っ込めます。出し入れは ScrollTrigger の、しきい値を越えたときと戻ったときの知らせで行います。縁の輪には読み進めた割合を描き、押すとページの先頭まで滑らかに戻ります。

出すときは 0.4 倍から 1 倍へ、24px 下から 0.5 秒の back.out(2) で弾ませ、引っ込めるときは 0.3 秒の power2.in で素早く消します。輪は SVG の円に pathLength を 100 と付け、破線のずれを 100 から 0 へ、ページの先頭から最後までのスクロール量にそのまま結びます。先頭へ戻す動きは ScrollToPlugin で 1.3 秒の power3.inOut にし、動き出しと着地をゆっくりにしています。

戻る途中にホイールや指で動かしたら、そこで止めます(autoKill)。止めないと、読む人の手とページの動きが取り合います。戻りきったら焦点をページの先頭の見出しへ移し、隠れたボタンに焦点が取り残されないようにしています。ボタンの名前は中の説明の文で、その説明はホバーとキーボードの焦点で左に出します。

輪は縁から 6px 内側に描き、白い縁で囲みます。縁ぴったりに描くと、暗い地の上で線が地に溶けて、どこまで読んだか分からなくなります。押したときに外へ広がる輪は、ボタンの下に来る地に合わせて色を選びます(暗い地なら明るい色)。

出すしきい値・戻る時間・ボタンの大きさと画面の端からの距離で調整します。動きを減らす設定では、弾みも戻る動きも一瞬にします。長い記事・レシピ・EC の一覧ページのように、下へ長く読み進めるページに向いています。

コード

HTML
<main class="recipes">
  <!-- 戻った先。戻りきったら焦点もここへ移すので、tabindex="-1" で焦点を受けられるようにしておく -->
  <h1 class="recipes__title" id="top" tabindex="-1">夏の食卓を、<br>さっぱりと。</h1>
  <p class="recipes__lead">ここに説明文が入ります。</p>

  <section class="recipes__section">
    <h2>朝ごはん</h2>
    <ul class="recipes__grid">
      <li><img src="/img/peach-yogurt-bowl.jpg" alt="" width="600" height="400"><h3>桃とヨーグルトのボウル</h3><p>10分・かんたん</p></li>
      <li><img src="/img/tomato-ricotta-toast.jpg" alt="" width="600" height="400"><h3>トマトとリコッタのトースト</h3><p>12分・かんたん</p></li>
      <li><img src="/img/citrus-pancakes.jpg" alt="" width="600" height="400"><h3>柑橘のリコッタパンケーキ</h3><p>25分・ふつう</p></li>
    </ul>
  </section>

  <section class="recipes__section">
    <h2>昼ごはん</h2>
    <ul class="recipes__grid">
      <li><img src="/img/green-goddess-salad.jpg" alt="" width="600" height="400"><h3>葉野菜のグリーンサラダ</h3><p>15分・かんたん</p></li>
      <li><img src="/img/charred-corn-tacos.jpg" alt="" width="600" height="400"><h3>焼きとうもろこしのタコス</h3><p>20分・かんたん</p></li>
      <li><img src="/img/chilled-melon-soup.jpg" alt="" width="600" height="400"><h3>冷たいメロンのスープ</h3><p>10分・かんたん</p></li>
    </ul>
  </section>

  <section class="recipes__section">
    <h2>晩ごはん</h2>
    <ul class="recipes__grid">
      <li><img src="/img/miso-aubergine.jpg" alt="" width="600" height="400"><h3>なすの味噌だれ焼き</h3><p>35分・ふつう</p></li>
      <li><img src="/img/lemon-herb-chicken.jpg" alt="" width="600" height="400"><h3>鶏もものレモンハーブ焼き</h3><p>45分・ふつう</p></li>
      <li><img src="/img/tomato-bean-stew.jpg" alt="" width="600" height="400"><h3>トマトと豆の煮込み</h3><p>40分・かんたん</p></li>
    </ul>
  </section>
</main>

<footer class="recipes-footer">
  <p class="recipes-footer__title">毎週日曜日に、新しいレシピを1品。</p>
  <p>届くのはレシピだけ。配信はいつでも停止できます。</p>
  <p class="recipes-footer__copy">© 2026 Hi! I'm HIGASHIYUKI®</p>
</footer>

<!-- トップへ戻るボタン。名前は中の説明(「ページの先頭へ」)の文になる -->
<a class="to-top" href="#top">
  <svg class="to-top__ring" viewBox="0 0 64 64" aria-hidden="true">
    <circle class="to-top__track" cx="32" cy="32" r="26" />
    <!-- 読み進めた割合。pathLength="100" なので、stroke-dashoffset を 100 → 0 と % のまま書ける -->
    <circle class="to-top__bar" cx="32" cy="32" r="26" pathLength="100"
      stroke-dasharray="100" stroke-dashoffset="100" transform="rotate(-90 32 32)" />
  </svg>
  <svg class="to-top__arrow" viewBox="0 0 64 64" aria-hidden="true">
    <path d="M32 43V22M22 31.5L32 22L42 31.5" />
  </svg>
  <span class="to-top__ripple" aria-hidden="true"></span>
  <span class="to-top__label">ページの先頭へ</span>
</a>

CSS
.to-top {
  --surface: #ffffff; /* ボタンの地(アイコンだけのボタンは白い丸) */
  --ink: #262626; /* 矢印と、読み進めた割合の輪 */
  --track: #898989; /* 輪の溝 */
  --ripple: #eeece6; /* 押したときに広がる輪。ボタンの下に来る地に合わせる(暗い地なら明るい色) */
  --edge: 48px; /* 画面の右と下からの距離 */
  position: fixed;
  right: var(--edge);
  bottom: var(--edge);
  z-index: 20;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background-color: var(--surface);
  /* 読み進めるまでは隠しておく。出し入れは JS(autoAlpha)が行う */
  opacity: 0;
  visibility: hidden;
}

.to-top:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.to-top__ring,
.to-top__arrow { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; }

/* 輪は縁から 6px 内側に描き、白い縁で囲む(縁ぴったりだと、暗い地の上で線が地に溶ける) */
.to-top__track { stroke: var(--track); stroke-width: 1; }
.to-top__bar { stroke: var(--ink); stroke-width: 2; stroke-linecap: round; }
.to-top__arrow { stroke: var(--ink); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

.to-top__ripple {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  border: 2px solid var(--ripple);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}

/* ホバーとキーボードの焦点で出す説明。ボタンが隠れているときは一緒に隠れる(visibility は inherit で出す) */
.to-top__label {
  position: absolute;
  top: 12px;
  right: calc(100% + 16px);
  display: flex;
  align-items: center;
  height: 40px;
  padding: 0 16px;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  translate: 10px 0;
  pointer-events: none;
  transition: opacity 0.2s cubic-bezier(0.55, 0, 1, 0.45), translate 0.2s cubic-bezier(0.55, 0, 1, 0.45), visibility 0.2s;
}

.to-top:hover .to-top__label,
.to-top:focus-visible .to-top__label {
  opacity: 1;
  visibility: inherit;
  translate: 0 0;
  transition-duration: 0.25s;
  transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
}

.recipes { padding: 96px clamp(24px, 7.5vw, 96px); background-color: #f2f1ed; color: #262626; }
.recipes__title { margin: 0 0 24px; font-size: clamp(40px, 6vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.recipes__lead { max-width: 560px; margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.recipes__section { margin-top: 64px; padding-top: 64px; border-top: 1px solid rgba(38, 38, 38, 0.12); }
.recipes__section h2 { margin: 0 0 32px; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.recipes__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; margin: 0; padding: 0; list-style: none; }
.recipes__grid li { padding: 16px 16px 24px; border-radius: 4px; background-color: #ffffff; }
.recipes__grid img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; border-radius: 2px; background-color: #f2e1e0; object-fit: cover; }
.recipes__grid h3 { margin: 16px 0 4px; font-size: 20px; line-height: 1.3; letter-spacing: -0.01em; word-break: auto-phrase; }
.recipes__grid p { margin: 0; color: #6b6b6b; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
.recipes-footer { padding: 64px clamp(24px, 7.5vw, 96px) 48px; background-color: #111111; color: #eeece6; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.recipes-footer__title { margin: 0 0 16px; word-break: auto-phrase; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.recipes-footer__copy { margin: 64px 0 0; color: #a8a6a0; font-size: 13px; }

@media (max-width: 560px) {
  .to-top { --edge: 24px; }
}

@media (prefers-reduced-motion: reduce) {
  .to-top__label { transition: none; }
}

JavaScript
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { ScrollToPlugin } from "gsap/ScrollToPlugin";

gsap.registerPlugin(ScrollTrigger, ScrollToPlugin);

const SHOW_AFTER = 0.6; // 画面の高さのこの割合を越えて読み進めたら出す
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const speed = reduceMotion ? 0 : 1; // 動きを減らす設定では、弾みも戻る動きも一瞬にする

document.querySelectorAll(".to-top").forEach((button) => {
  const bar = button.querySelector(".to-top__bar");
  const arrow = button.querySelector(".to-top__arrow");
  const ripple = button.querySelector(".to-top__ripple");
  const target = document.querySelector(button.hash);

  gsap.set(button, { autoAlpha: 0, scale: 0.4, y: 24 });

  // 読み進めた割合を輪に描く。ページの先頭から最後までを、スクロール量にそのまま連動させる
  gsap.fromTo(
    bar,
    { strokeDashoffset: 100 },
    { strokeDashoffset: 0, ease: "none", scrollTrigger: { start: 0, end: "max", scrub: true } }
  );

  // しきい値を越えたら弾んで出し、それより上へ戻したら引っ込める
  const show = (on) =>
    gsap.to(
      button,
      on
        ? { autoAlpha: 1, scale: 1, y: 0, duration: 0.5 * speed, ease: "back.out(2)", overwrite: true }
        : { autoAlpha: 0, scale: 0.4, y: 24, duration: 0.3 * speed, ease: "power2.in", overwrite: true }
    );
  ScrollTrigger.create({
    start: () => innerHeight * SHOW_AFTER,
    end: "max", // 最後まで読んで下端を越えても、出したままにする(onLeave では引っ込めない)
    onEnter: () => show(true),
    onLeaveBack: () => show(false),
  });

  button.addEventListener("click", (event) => {
    event.preventDefault(); // ページ内リンクの一瞬の飛び方の代わりに、滑らかに戻す
    if (!reduceMotion) {
      // 押した手応え: 矢印を少し上へ跳ねさせ、輪を外へ広げて消す
      gsap.timeline()
        .to(arrow, { y: -5, duration: 0.12, ease: "power2.out" })
        .to(arrow, { y: 0, duration: 0.35, ease: "power2.inOut" });
      gsap.fromTo(ripple, { scale: 1, opacity: 0.8 }, { scale: 1.75, opacity: 0, duration: 0.6, ease: "power2.out" });
    }
    gsap.to(window, {
      scrollTo: { y: 0, autoKill: true }, // 戻る途中にホイールや指で動かしたら、そこで止める
      duration: 1.3 * speed,
      delay: 0.12 * speed,
      ease: "power3.inOut",
      // ボタンは隠れるので、焦点をページの先頭へ移す(キーボードで押した人が、先頭から読み直せる)
      onComplete: () => target?.focus({ preventScroll: true }),
    });
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。