本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

本文の文字を選ぶと編集のツールバーが浮かび、選んだ範囲に合わせて動く

デモ

操作:ドラッグ・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

本文の文字をなぞって選ぶと、選んだ範囲の真上に書式のツールバーが弾んで浮かびます。範囲を選び直すと、ツールバーは畳まずに新しい範囲の上へ滑って移ります。「マーカー」を押すと選んだ語に色の帯が左から引かれ、ツールバーは矢印の先へ畳まれて消えます。同じ語をもう一度選んで押すと、帯は左へ戻って消えます。画面の端に置いた書式の列と違い、道具が手の止まっている場所へ付いてくるので、目を大きく動かさずに書式を変えられます。

出るときは0.4秒の back.out(2.4) で、0.55倍から少し行き過ぎて収まります。畳むときは0.22秒の power2.in です。膨らませる起点は、下端の矢印の先に置きます。既定の中央のままだと、膨らむあいだ矢印が範囲から離れたり近づいたりします。選び直したときに追う時間は0.28秒で、短くすると範囲にぴたりと付き、長くすると遅れて滑ります。

位置は、選んだ範囲の外接する四角の上辺の中央に合わせます。なぞっている最中は範囲が文字の単位で伸びるので、位置をそのまま追うと小刻みに飛びます。直前の0.2秒ほどの平均を取ると、なぞって伸びるときも、別の段落へ飛ぶときも、同じ式で滑らかに追えます。帯は background-size を0%から広げて引くので、語が行をまたいで折り返しても続けて伸びます。

書式は、選んだ範囲を span で包んで付けます。範囲がほかの span をまたいでいると包めないので、そのときは中身をいったん切り出して包み直します。外すときは、帯を左へ戻しきってから span をほどき、隣り合った文字をつなぎ直します。ボタンへ焦点が移ると選択は外れるので、範囲は選び終えた時点で覚えておき、押されたときはその範囲に当てます。今の書式が当たっているかはボタンの aria-pressed で伝えます。

帯は選択の色の下に隠れてしまうので、押したところで選択を外します。Shift と矢印キーで選んでもツールバーは同じように出るので、キーボードだけでも使えます。動きを減らす設定では、出入りと移りをほぼ一瞬にします。記事の下書き、社内のドキュメント、コメント欄のように、読みながらその場で書式を変えたい編集画面に向いています。

コード

HTML
<!-- ツールバーは本文と同じ入れ物の中に置く(位置は入れ物からの相対で決める) -->
<div class="edit">
  <!-- 本文は編集できる領域にする。キャレットが入るので、Shift と矢印キーだけでも範囲を選べる -->
  <div class="edit__body" contenteditable="true" spellcheck="false" role="textbox" aria-multiline="true" aria-label="本文">
    <p>サイトをつくるときは、はじめに目的とターゲットを決めておくことが大切です。ここがはっきりしていると、デザインや文章で迷ったときの判断がぶれません。</p>
    <p>次に、載せる内容を書き出して、優先順位をつけて整理します。すべてを同じ大きさで並べるより、伝えたいことが届きやすくなります。</p>
    <p>公開したあとは、アクセスの数字を見ながら少しずつ改善していきます。はじめから完璧をめざすより、続けて直していくほうがうまくいきます。</p>
  </div>

  <!-- 押すと書式が入れ替わるので、状態は aria-pressed で伝える -->
  <div class="edit__bar" role="toolbar" aria-label="選んだ文字の書式" hidden>
    <button type="button" data-fmt="bold" aria-pressed="false">太字</button>
    <button type="button" data-fmt="link" aria-pressed="false">リンク</button>
    <button type="button" data-fmt="quote" aria-pressed="false">引用</button>
    <button type="button" data-fmt="mark" aria-pressed="false">マーカー</button>
  </div>
</div>

CSS
.edit {
  --ink: #262626; /* 文字 */
  --line: rgba(38, 38, 38, 0.12); /* 罫線 */
  --bar: #262626; /* ツールバーの地(明暗の反転) */
  --bar-ink: #eeece6; /* ツールバーの文字 */
  --mark: #d0c998; /* マーカーの帯 */
  --sel: #c5d1d8; /* 選んでいる範囲の地 */
  position: relative;
  max-width: 704px;
  margin: 0 auto;
  color: var(--ink);
}

.edit ::selection { background-color: var(--sel); }
.edit__body { border-radius: 4px; outline-offset: 8px; }
.edit__body:focus-visible { outline: 2px solid var(--ink); }
.edit__body p { margin: 0 0 28px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

/* 書式は、選んだ範囲を包んだ span に付く。帯は background-size を 0% から広げて左から引く */
[data-fmt-on] { background-repeat: no-repeat; background-size: 0% 100%; border-radius: 2px; }
[data-fmt-on="bold"] { font-weight: 700; }
[data-fmt-on="mark"] { background-image: linear-gradient(var(--mark), var(--mark)); }
[data-fmt-on="quote"] { background-image: linear-gradient(rgba(38, 38, 38, 0.08), rgba(38, 38, 38, 0.08)); font-style: italic; }
/* 下線は、下の 2px だけを塗る勾配にすると、帯と同じ書き方で左から引ける */
[data-fmt-on="link"] { background-image: linear-gradient(to top, var(--ink) 2px, transparent 2px); }

.edit__bar {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  gap: 2px;
  align-items: center;
  height: 44px;
  padding: 0 6px;
  border-radius: 10px;
  background-color: var(--bar);
  color: var(--bar-ink);
  /* 矢印の先を起点に膨らませる(既定の中央のままだと、矢印が選んだ範囲から離れる) */
  transform-origin: 50% 100%;
}

/* 上に入らないときは範囲の下へ回す(矢印も裏返す) */
.edit__bar--under { transform-origin: 50% 0; }
.edit__bar--under::after { top: auto; bottom: 100%; border-top: 0; border-bottom: 7px solid var(--bar); }

/* display を持つ要素には hidden 属性が効かない。あとから書いて display: flex を上書きする */
.edit__bar[hidden] { display: none; }

/* 下の中央の矢印。三角は border で描く */
.edit__bar::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  margin-left: -7px;
  border-top: 7px solid var(--bar);
  border-right: 7px solid transparent;
  border-left: 7px solid transparent;
}

.edit__bar button {
  height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: 6px;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.edit__bar button:hover { background-color: #3f3f3f; }
.edit__bar button[aria-pressed="true"] { background-color: var(--bar-ink); color: #111111; }
.edit__bar button:focus-visible { outline: 2px solid var(--bar-ink); outline-offset: -4px; }
.edit__bar button[aria-pressed="true"]:focus-visible { outline-color: #111111; }

@media (max-width: 480px) {
  .edit__bar { height: 40px; }
  .edit__bar button { padding: 0 10px; font-size: 12px; }
}

JavaScript
import { gsap } from "gsap";

const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduce ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const FOLLOW = reduce ? 0.01 : 0.28; // 選び直したときに追う時間(0 にすると quickTo が動かなくなる)
const GAP = 14; // 選んだ範囲の上端と、矢印の先のあいだ

const edit = document.querySelector(".edit");
const body = edit.querySelector(".edit__body");
const bar = edit.querySelector(".edit__bar");
const buttons = [...bar.querySelectorAll("[data-fmt]")];
const place = gsap.quickTo(bar, "x", { duration: FOLLOW, ease: "power3.out" });
const lift = gsap.quickTo(bar, "y", { duration: FOLLOW, ease: "power3.out" });
let saved = null; // 最後に選んでいた範囲(ボタンへ焦点が移ると選択が消えるので、覚えておく)
let shown = false;

// 選んだ範囲の上辺の中央に合わせる。入れ物からの相対で置き、はみ出すときは内側へ寄せる
function at(range) {
  const box = edit.getBoundingClientRect();
  const r = range.getBoundingClientRect();
  const over = r.top - box.top - bar.offsetHeight - GAP;
  const under = over < 0; // 上に入らないときは、範囲の下へ回す
  bar.classList.toggle("edit__bar--under", under);
  const x = r.left + r.width / 2 - box.left - bar.offsetWidth / 2;
  return { x: Math.max(0, Math.min(x, box.width - bar.offsetWidth)), y: under ? r.bottom - box.top + GAP : over };
}

function show(range) {
  if (shown) {
    const to = at(range);
    place(to.x); // 出ているあいだは畳まずに、新しい範囲の上へ滑って移る
    lift(to.y);
    return;
  }
  shown = true;
  bar.hidden = false;
  gsap.set(bar, at(range));
  gsap.fromTo(bar, { autoAlpha: 0, scale: 0.55 }, { autoAlpha: 1, scale: 1, duration: 0.4, ease: "back.out(2.4)" }).timeScale(SPEED);
}

function hide() {
  if (!shown) return;
  shown = false;
  gsap.to(bar, { autoAlpha: 0, scale: 0.55, duration: 0.22, ease: "power2.in", onComplete: () => (bar.hidden = true) }).timeScale(SPEED);
}

// 範囲がその書式の span の中にあるか(あれば、押したときは外す)
const wrapped = (kind) => saved && saved.startContainer.parentElement?.closest(`[data-fmt-on="${kind}"]`);

function apply(kind) {
  const range = saved;
  if (!range || range.collapsed) return;
  const already = wrapped(kind);
  if (already) {
    // 帯を左へ戻してから、包んだ span をほどく
    gsap.to(already, { backgroundSize: "0% 100%", duration: 0.26, ease: "power2.out", onComplete: () => {
      already.replaceWith(...already.childNodes);
      body.normalize();
    } }).timeScale(SPEED);
  } else {
    const span = document.createElement("span");
    span.dataset.fmtOn = kind;
    // 範囲がほかの span をまたぐと surroundContents は失敗するので、切り出して包み直す
    try {
      range.surroundContents(span);
    } catch {
      span.append(range.extractContents());
      range.insertNode(span);
    }
    gsap.fromTo(span, { backgroundSize: "0% 100%" }, { backgroundSize: "100% 100%", duration: 0.32, ease: "power2.out" }).timeScale(SPEED);
  }
  window.getSelection().removeAllRanges(); // 帯が選択の色に隠れないように外す
  saved = null;
  hide();
}

// 本物の選択はブラウザが作るので、受けるのは変化だけ。Shift + 矢印キーで選んでも同じように出る
document.addEventListener("selectionchange", () => {
  const sel = window.getSelection();
  if (!sel.rangeCount || sel.isCollapsed || !body.contains(sel.anchorNode)) {
    if (!bar.contains(document.activeElement)) hide(); // ボタンへ焦点が移ったときは畳まない
    return;
  }
  saved = sel.getRangeAt(0).cloneRange();
  for (const button of buttons) button.setAttribute("aria-pressed", String(Boolean(wrapped(button.dataset.fmt))));
  show(saved);
});
bar.addEventListener("mousedown", (event) => event.preventDefault()); // 押しても選択が外れないようにする
for (const button of buttons) button.addEventListener("click", () => apply(button.dataset.fmt));
document.addEventListener("keydown", (event) => {
  if (event.key !== "Escape" || !shown) return;
  window.getSelection().removeAllRanges();
  saved = null;
  hide();
});

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

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