デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 リスト、買い物リスト、進行管理の表、手順のチェックリストに向いています。
コード
<!-- 行は 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>
.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; }
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();
});