本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

やることにチェックすると取り消し線が引かれ、行が下へ移る

デモ

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

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

やることのチェックボックスを押すと、箱が緑に塗られて白いチェックが描かれ、文字に取り消し線が引かれて薄くなります。線を引き終えると、その行が「完了」の並びの先頭へ滑って移り、下の行は詰まって上がります。チェックを外すと線が巻き戻って文字の色が戻り、行は「未完了」の元の順番の位置へ戻ります。

行を移すのは FLIP という型で、コードの節では GSAP の Flip プラグインで組んでいます。移す前にすべての行と「完了」の見出しの位置を測り、DOM の並びを入れ替えてから、測った位置から新しい位置へ 0.6 秒の power3.inOut で補間します。行が抜けて詰まる動きも、見出しが上がる動きも、同じ 1 回の補間で済みます。

チェックを付けてから行が動き出すまで、0.5 秒待ちます。すぐ移すと、押した行が目の前から消えて、何が起きたか分かりません。待っている間に押し直されたら、移すのをやめます。取り消し線は文字の背景に引いた 2px の帯で、--strike という CSS 変数で長さを伸ばすので、折り返した長い文にも行をまたいで続けて引かれます。

要素を DOM の中で動かすと、中のチェックボックスから焦点が外れます。キーボードで操作している人が迷わないよう、移したあとに焦点を戻します。本物のチェックボックスを見えなくして label で包んでいるので、行のどこを押しても、スペースキーでも切り替わり、フォームでそのまま送れます。

完了した行は、地に成功の緑を少し混ぜ、文字を灰色にして区別します。待つ時間を短くすると手早い印象に、移る時間を長くすると落ち着いた印象になります。動きを減らす設定では、描き込みも移動も一瞬で済ませます。ToDo リスト、買い物リスト、進行管理の表、手順のチェックリストに向いています。

コード

HTML
<!-- 行は label で包んだ本物のチェックボックス。data-order は、未完了へ戻すときの元の順番 -->
<section class="todo" aria-labelledby="todo-title">
  <div class="todo__head">
    <h2 class="todo__title" id="todo-title">今日のやること</h2>
    <p class="todo__count" aria-live="polite"></p>
  </div>
  <h3 class="todo__label" id="todo-open">未完了<span class="todo__num"></span></h3>
  <ul class="todo__list" aria-labelledby="todo-open">
    <li class="todo__item" data-order="0">
      <label class="todo__row">
        <input class="todo__check" type="checkbox" name="done" value="estimate">
        <span class="todo__box" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 10 17.5 19 7.5" pathLength="1" /></svg></span>
        <span class="todo__body"><span class="todo__text">見積書を送る</span></span>
        <span class="todo__due">今日</span>
      </label>
    </li>
    <li class="todo__item" data-order="1">
      <label class="todo__row">
        <input class="todo__check" type="checkbox" name="done" value="logo">
        <span class="todo__box" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 10 17.5 19 7.5" pathLength="1" /></svg></span>
        <span class="todo__body"><span class="todo__text">ロゴの修正案を3つ作る</span></span>
        <span class="todo__due">今日</span>
      </label>
    </li>
    <li class="todo__item" data-order="2">
      <label class="todo__row">
        <input class="todo__check" type="checkbox" name="done" value="minutes">
        <span class="todo__box" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 10 17.5 19 7.5" pathLength="1" /></svg></span>
        <span class="todo__body"><span class="todo__text">打ち合わせの議事録をまとめる</span></span>
        <span class="todo__due">明日</span>
      </label>
    </li>
    <li class="todo__item" data-order="3">
      <label class="todo__row">
        <input class="todo__check" type="checkbox" name="done" value="invoice">
        <span class="todo__box" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 10 17.5 19 7.5" pathLength="1" /></svg></span>
        <span class="todo__body"><span class="todo__text">請求書を発行する</span></span>
        <span class="todo__due">9月26日</span>
      </label>
    </li>
  </ul>
  <h3 class="todo__label" id="todo-done">完了<span class="todo__num"></span></h3>
  <ul class="todo__list" aria-labelledby="todo-done">
    <li class="todo__item" data-order="4">
      <label class="todo__row">
        <input class="todo__check" type="checkbox" name="done" value="kickoff" checked>
        <span class="todo__box" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5 10 17.5 19 7.5" pathLength="1" /></svg></span>
        <span class="todo__body"><span class="todo__text">キックオフの日程を決める</span></span>
        <span class="todo__due">9月22日</span>
      </label>
    </li>
  </ul>
</section>

CSS
.todo {
  --ink: #262626;             /* 文字 */
  --mute: #898989;            /* 外れている箱の枠・件数 */
  --mute-small: #6b6b6b;      /* 見出しと期日(16px 未満の補足) */
  --paper: #f2f1ed;           /* 行の地 */
  --surface: #ffffff;         /* リストの面 */
  --done: #4f8a64;            /* 付いている箱(成功の色) */
  --done-tile: #dfe6df;       /* 完了した行の地(行の地に --done を 12% 混ぜた色) */
  --done-ink: #5e5e5e;        /* 完了した行の文字と取り消し線(--done-tile の上で 4.5:1) */
  max-width: 560px;
  padding: 32px;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
}

.todo__head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; }
.todo__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.todo__count { margin: 0; color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }

.todo__label {
  margin: 24px 0 12px;
  color: var(--mute-small);
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.06em;
}

.todo__num { margin-left: 8px; color: var(--mute); font-variant-numeric: tabular-nums; }

.todo__list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }

/* 移っている行は、ほかの行の上を通る */
.todo__item { position: relative; }
.todo__item.is-moving { z-index: 1; }

.todo__row {
  display: grid;
  grid-template-columns: 24px 1fr auto;
  align-items: center;
  gap: 16px;
  min-height: 56px;
  padding: 12px 16px;
  box-sizing: border-box;
  border-radius: 4px;
  background-color: var(--paper);
  cursor: pointer;
}

/* 本物のチェックボックスは見えなくし、キーボードと読み上げには残す */
.todo__check { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }

.todo__box {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  box-sizing: border-box;
  border: 2px solid var(--mute);
  border-radius: 6px;
}

.todo__check:focus-visible + .todo__box { outline: 2px solid var(--ink); outline-offset: 2px; }

/* 画面の上で線が 2px になる太さ(24 の枠を 16px で描く) */
.todo__box svg { width: 16px; height: 16px; fill: none; stroke: #ffffff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }

/* 取り消し線は背景の帯。--strike(0〜1)で左から伸び、折り返した文にも行をまたいで続けて引かれる */
.todo__text {
  --strike: 0;
  background: linear-gradient(currentColor, currentColor) no-repeat 0 55% / calc(var(--strike) * 100%) 2px;
  font-size: 16px;
  line-height: 1.6;
  letter-spacing: 0.04em;
  word-break: auto-phrase; /* 狭い画面では文節で折り返す */
}

.todo__due { color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; white-space: nowrap; }

JavaScript
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";

gsap.registerPlugin(Flip);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const WAIT = 0.5; // 付けてから行が動き出すまで(線を引き終えるのを待つ)
const MOVE = 0.6; // 行が移る時間

document.querySelectorAll(".todo").forEach((todo) => {
  const [openList, doneList] = todo.querySelectorAll(".todo__list");
  const [openNum, doneNum] = todo.querySelectorAll(".todo__num");
  const doneLabel = todo.querySelectorAll(".todo__label")[1];
  const count = todo.querySelector(".todo__count");
  const items = [...todo.querySelectorAll(".todo__item")];
  // 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const styles = getComputedStyle(todo);
  const color = (name) => styles.getPropertyValue(name).trim();
  const done = color("--done");
  const [r, g, b] = gsap.utils.splitColor(done);
  const clear = `rgba(${r}, ${g}, ${b}, 0)`; // 透明から塗ると途中が黒ずむので、同じ色の透明から

  function recount() {
    const checked = items.filter((item) => item.querySelector(".todo__check").checked).length;
    count.textContent = `${items.length}件中${checked}件完了`;
    openNum.textContent = items.length - checked;
    doneNum.textContent = checked;
  }

  // 行を移す。動く前の位置を測り、並びを入れ替えてから、測った位置から新しい位置へ補間する(FLIP)
  function moveItem(item, input) {
    const state = Flip.getState([...items, doneLabel]);
    const hadFocus = item.contains(document.activeElement);
    if (input.checked) {
      doneList.prepend(item);
    } else {
      // 元の順番(data-order)の位置へ戻す
      const next = [...openList.children].find((li) => Number(li.dataset.order) > Number(item.dataset.order));
      openList.insertBefore(item, next ?? null);
    }
    // 要素を動かすと焦点が外れるので、押した人の焦点を戻す
    if (hadFocus) input.focus({ preventScroll: true });
    item.classList.add("is-moving");
    Flip.from(state, {
      duration: reduceMotion ? 0 : MOVE,
      ease: "power3.inOut",
      onComplete: () => item.classList.remove("is-moving"),
    });
  }

  items.forEach((item) => {
    const input = item.querySelector(".todo__check");
    const row = item.querySelector(".todo__row");
    const box = item.querySelector(".todo__box");
    const mark = item.querySelector(".todo__box path");
    const text = item.querySelector(".todo__text");
    gsap.set(mark, { strokeDasharray: 1 }); // pathLength="1" なので、1 が線の全長

    // 箱が塗られてチェックが描かれ、少し遅れて取り消し線が伸び、文字と行の地が完了の色になる
    const check = gsap
      .timeline({ paused: true })
      .fromTo(box, { backgroundColor: clear, borderColor: color("--mute") }, { backgroundColor: done, borderColor: done, duration: 0.15, ease: "none" }, 0)
      // 丸い線端は長さ 0 でも点が残るので、描き始めるまで透明にしておく
      .fromTo(mark, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 0.08)
      .fromTo(mark, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 0.3, ease: "power2.out" }, 0.08)
      .fromTo(text, { "--strike": 0, color: color("--ink") }, { "--strike": 1, color: color("--done-ink"), duration: 0.35, ease: "power2.inOut" }, 0.2)
      .fromTo(row, { backgroundColor: color("--paper") }, { backgroundColor: color("--done-tile"), duration: 0.3, ease: "none" }, 0.2);
    if (input.checked) check.progress(1);
    if (reduceMotion) check.timeScale(20);

    let wait;
    input.addEventListener("change", () => {
      // 外すときは 1.5 倍速で巻き戻す
      if (input.checked) check.timeScale(reduceMotion ? 20 : 1).play();
      else check.timeScale(reduceMotion ? 20 : 1.5).reverse();
      recount();
      wait?.kill(); // 移る前に押し直されたら、移すのをやめる
      const target = input.checked ? doneList : openList;
      if (item.parentElement === target) return;
      wait = gsap.delayedCall(reduceMotion ? 0 : WAIT, () => moveItem(item, input));
    });
  });

  recount();
});

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

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