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

削除の確認ダイアログで、確かめの言葉を打つと削除のボタンが使えるようになる

デモ

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

仕様

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

しくみと調整

一覧の行のごみ箱を押すと、後ろのページに幕がかかり、確認のダイアログが少し下から浮かびます。「削除するプロジェクトの名前を入力してください」の欄にその行の名前を打ち、正しく一致した時点で、灰色で押せなかった「削除する」がエラーの色に塗られて押せるようになります。違う名前のまま押したり Enter を押したりすると、欄が小さく震えて「名前が一致しません」と出ます。削除すると、ダイアログが消えてから行の中身が消え、行の高さが畳まれて下の行が詰まり、画面の下に「プロジェクトを削除しました」の知らせが出ます。

ダイアログは 0.4 秒の power3.out で 16px 下から浮かべるだけで、拡大や弾みは付けません。弾みながら開くモーダルと違い、取り消せない操作の前なので、目を引くより落ち着いて読ませる出方にしています。震えは 0.36 秒で 3 往復、振れ幅は 5px です。誤入力を強く知らせるフォームの震えよりずっと小さくし、押せない理由があることだけを伝えます。行は中身を 0.2 秒で先に消し、0.1 秒遅れて高さを 0.45 秒の power2.inOut で 0 まで畳むので、文字が潰れて見えません。

押せない「削除する」に disabled を付けると、押しても何も起きず焦点も当たらないので、なぜ押せないのかが伝わりません。aria-disabled にして押せるまま残し、送るたびに名前を比べて、合わなければ送らずに震えと文で理由を見せます。aria-disabled だけでは送信は止まらないので、送信の受け口で必ず既定の動きを止めます。名前は前後の空白だけを落として比べます。

ダイアログは showModal で開くので、焦点の閉じ込めと、閉じたあとにごみ箱のボタンへ焦点を戻すことを任せられます。Esc は cancel を止めて、同じ動きで閉じます。行を消すときは、消える行のボタンに焦点を残さないよう、次の行のボタンへ移します。欄の下の文は aria-live の領域にし、知らせは読み上げの入れ物を隠さずに文だけを入れ替えて届けます。

確かめの言葉は名前でなく「削除」のような決まった語にしてもよく、打たせる手間が軽くなります。震えの振れ幅と回数、行を畳む時間が調整のつまみです。動きを減らす設定では、震えを止めて枠の色と文だけで知らせ、ほかの動きはほぼ一瞬にします。プロジェクトやアカウント、公開中のページ、登録したカードなど、取り消せないものを消す前の確認に向いています。

コード

HTML
<!-- 一覧の行。data-name が、削除のときに打ってもらう確かめの言葉になる -->
<ul class="projects">
  <li class="project" data-name="春の新生活フェア">
    <span class="project__thumb" data-tone="slate"></span>
    <span class="project__name">春の新生活フェア<small>公開中</small></span>
    <span class="project__meta">9月12日</span>
    <span class="project__meta">3人</span>
    <button class="project__delete" type="button" aria-label="「春の新生活フェア」を削除">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
    </button>
  </li>
  <li class="project" data-name="店舗サイトのリニューアル">
    <span class="project__thumb" data-tone="sage"></span>
    <span class="project__name">店舗サイトのリニューアル<small>公開中</small></span>
    <span class="project__meta">9月20日</span>
    <span class="project__meta">6人</span>
    <button class="project__delete" type="button" aria-label="「店舗サイトのリニューアル」を削除">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
    </button>
  </li>
  <li class="project" data-name="秋の新作キャンペーン">
    <span class="project__thumb" data-tone="greige"></span>
    <span class="project__name">秋の新作キャンペーン<small>公開中</small></span>
    <span class="project__meta">9月26日</span>
    <span class="project__meta">4人</span>
    <button class="project__delete" type="button" aria-label="「秋の新作キャンペーン」を削除">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
    </button>
  </li>
  <li class="project" data-name="採用ページの改修">
    <span class="project__thumb" data-tone="rose"></span>
    <span class="project__name">採用ページの改修<small>公開中</small></span>
    <span class="project__meta">9月27日</span>
    <span class="project__meta">2人</span>
    <button class="project__delete" type="button" aria-label="「採用ページの改修」を削除">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
    </button>
  </li>
  <li class="project" data-name="会員向けニュースレター">
    <span class="project__thumb" data-tone="lavender"></span>
    <span class="project__name">会員向けニュースレター<small>公開中</small></span>
    <span class="project__meta">9月28日</span>
    <span class="project__meta">5人</span>
    <button class="project__delete" type="button" aria-label="「会員向けニュースレター」を削除">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>
    </button>
  </li>
</ul>

<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・閉じたあとの焦点の戻しが付いてくる -->
<dialog class="confirm" aria-labelledby="confirm-title" aria-describedby="confirm-desc">
  <div class="confirm__scrim" data-confirm-cancel></div>
  <form class="confirm__panel" novalidate>
    <h2 class="confirm__title" id="confirm-title">プロジェクトを削除しますか?</h2>
    <p class="confirm__desc" id="confirm-desc">「<span data-confirm-name></span>」を削除すると、ページ・画像・分析のデータもすべて消え、元に戻せません。</p>
    <label class="confirm__label" for="confirm-input">削除するプロジェクトの名前を入力してください</label>
    <div class="confirm__field">
      <input class="confirm__input" id="confirm-input" type="text" autocomplete="off" spellcheck="false" aria-describedby="confirm-hint">
    </div>
    <!-- 3 つの文言は data-* に置き、状態に合わせて入れ替える -->
    <p class="confirm__hint" id="confirm-hint" aria-live="polite" data-idle="名前が一致すると、削除できるようになります" data-error="名前が一致しません" data-ok="名前が一致しました"></p>
    <div class="confirm__actions">
      <button class="confirm__button" type="button" data-confirm-cancel>キャンセル</button>
      <!-- disabled にすると押しても何も起きず、焦点も当たらない。aria-disabled にして、押されたら理由を見せる -->
      <button class="confirm__button confirm__remove" type="submit" aria-disabled="true">削除する</button>
    </div>
  </form>
</dialog>

<!-- 知らせ。role="status" の入れ物は隠さず、中の文を入れ替えて読み上げに届ける -->
<div class="toast" role="status" data-message="プロジェクトを削除しました">
  <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" /><path d="M7 12.5l3.2 3.2L17 9" /></svg>
  <span></span>
</div>

CSS
.projects, .confirm, .toast {
  --ink: #262626; /* 文字 */
  --mute: #6b6b6b; /* 小さい補足の文字 */
  --surface: #ffffff; /* ダイアログと知らせの地 */
  --line: rgba(38, 38, 38, 0.12); /* 罫線 */
  --danger: #c4553d; /* 押せるようになった「削除する」 */
  --danger-text: #a8432d; /* 小さいエラーの文字 */
  --ok: #4f8a64; /* 知らせの印 */
  --ok-text: #3f7352; /* 小さい成功の文字 */
  --idle: #c8c7c3; /* 押せない間のボタン */
  --scrim: rgba(17, 17, 17, 0.45); /* 後ろを暗くする幕 */
  color: var(--ink);
}

.projects { margin: 0; padding: 0; list-style: none; box-shadow: inset 0 1px 0 var(--line); }

/* 行の高さは GSAP が 0 まで畳む。はみ出しを切り、罫線は行の中に引く */
.project { display: flex; align-items: center; gap: 16px; height: 72px; overflow: hidden; box-shadow: inset 0 -1px 0 var(--line); }

.project__thumb { flex: none; width: 40px; height: 40px; border-radius: 4px; }
.project__thumb[data-tone="slate"] { background: #688297; }
.project__thumb[data-tone="sage"] { background: #739374; }
.project__thumb[data-tone="greige"] { background: #A28767; }
.project__thumb[data-tone="rose"] { background: #b27771; }
.project__thumb[data-tone="lavender"] { background: #877fa1; }
.project__name { flex: 1; min-width: 0; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; }
.project__name small { display: block; margin-top: 6px; color: var(--mute); font-size: 12px; font-weight: 400; }
.project__meta { flex: none; width: 120px; font-size: 14px; font-variant-numeric: tabular-nums; }

/* アイコンだけのボタンは線の丸 */
.project__delete {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px; height: 40px;
  padding: 0; border: 0; border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--ink);
  background: none; color: var(--ink);
  cursor: pointer;
}

.project__delete svg, .toast svg { fill: none; stroke: currentColor; stroke-width: 2.67; stroke-linecap: round; stroke-linejoin: round; }
.project__delete svg { width: 18px; height: 18px; }

/* dialog を画面いっぱいに広げ、幕とパネルを中に置く */
.confirm { width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0; border: 0; background: none; overflow: clip; }
.confirm::backdrop { background: none; }
.confirm[open] { display: grid; place-items: center; }
.confirm__scrim { position: absolute; inset: 0; background: var(--scrim); }
html:has(.confirm[open]) { overflow: hidden; }

.confirm__panel { position: relative; box-sizing: border-box; width: min(520px, calc(100% - 32px)); padding: 32px; border-radius: 8px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); word-break: auto-phrase; }

.confirm__title { margin: 0 0 12px; font-size: 20px; line-height: 1.3; letter-spacing: -0.01em; }
.confirm__desc { margin: 0 0 24px; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
.confirm__label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 600; letter-spacing: 0.04em; }
.confirm__input { box-sizing: border-box; width: 100%; height: 48px; padding: 0 16px; border: 0; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line); background: var(--surface); color: var(--ink); font: inherit; font-size: 16px; letter-spacing: 0.02em; }
.confirm__input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--ink); }
.confirm__field.is-error .confirm__input { box-shadow: inset 0 0 0 1px var(--danger); }
.confirm__hint { min-height: 13px; margin: 12px 0 24px; color: var(--mute); font-size: 13px; line-height: 1; letter-spacing: 0.04em; }
.confirm__hint[data-state="error"] { color: var(--danger-text); }
.confirm__hint[data-state="ok"] { color: var(--ok-text); }
.confirm__actions { display: flex; justify-content: flex-end; gap: 12px; }
.confirm__button { height: 48px; padding: 0 28px; border: 0; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ink); background: none; color: var(--ink); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }

/* 押せない間も焦点は当たる。押されたら欄を震わせて理由を見せる */
.confirm__remove { box-shadow: none; background: var(--danger); color: #ffffff; transition: background-color 0.25s; }
.confirm__remove[aria-disabled="true"] { background: var(--idle); cursor: not-allowed; }
.confirm button:focus-visible, .project__delete:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* 知らせは画面の下の中央(横の中央そろえは GSAP の xPercent)。入れ物は隠さず、濃さだけで出し入れする */
.toast {
  position: fixed;
  bottom: 32px; left: 50%;
  display: flex;
  align-items: center;
  gap: 12px;
  height: 48px;
  padding: 0 20px; border-radius: 8px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: 14px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap;
  opacity: 0;
  pointer-events: none;
}

.toast svg { width: 22px; height: 22px; color: #ffffff; }
.toast circle { fill: var(--ok); stroke: none; }
@media (max-width: 640px) { .project__meta { display: none; } }
@media (prefers-reduced-motion: reduce) { .confirm__remove { transition-duration: 0.01s; } }

JavaScript
import { gsap } from "gsap";

const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduce ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const SHAKE = 5; // 震えの振れ幅(px)
const dialog = document.querySelector(".confirm");
const scrim = dialog.querySelector(".confirm__scrim");
const form = dialog.querySelector(".confirm__panel");
const input = form.querySelector(".confirm__input");
const field = form.querySelector(".confirm__field");
const hint = form.querySelector(".confirm__hint");
const remove = form.querySelector(".confirm__remove");
const toast = document.querySelector(".toast");
gsap.set(toast, { xPercent: -50 }); // 中央そろえは GSAP に持たせる(CSS の translate だと y と取り合う)
let row = null; // 削除しようとしている行
let motion, hideLater;

const matches = () => Boolean(row) && input.value.trim() === row.dataset.name;

// 文言は data-idle・data-error・data-ok から読む(HTML だけ直せば変わる)
function setHint(state) {
  hint.textContent = hint.dataset[state];
  hint.dataset.state = state;
  field.classList.toggle("is-error", state === "error");
}

function update() {
  remove.setAttribute("aria-disabled", String(!matches()));
  setHint(matches() ? "ok" : "idle");
}

function open(target) {
  row = target;
  dialog.querySelector("[data-confirm-name]").textContent = row.dataset.name;
  input.value = "";
  update();
  motion?.kill();
  dialog.showModal();
  input.focus();
  motion = gsap.timeline()
    .fromTo(scrim, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "none" }, 0)
    .fromTo(form, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.4, ease: "power3.out" }, 0)
    .timeScale(SPEED);
}

function close(then) {
  motion?.kill();
  motion = gsap.timeline({ onComplete: () => { dialog.close(); then?.(); } })
    .to(form, { opacity: 0, y: 12, duration: 0.22, ease: "power2.in" }, 0)
    .to(scrim, { opacity: 0, duration: 0.28, ease: "none" }, 0)
    .timeScale(SPEED);
}

// 行は中身を先に消してから高さを畳み、下の行を詰める
function fold(target) {
  const next = target.nextElementSibling ?? target.previousElementSibling;
  next?.querySelector("button").focus(); // 消える行のボタンに焦点を残さない
  gsap.timeline({ onComplete: () => target.remove() })
    .to(target.children, { opacity: 0, duration: 0.2, ease: "none" })
    .to(target, { height: 0, duration: 0.45, ease: "power2.inOut" }, 0.1)
    .add(showToast, 0.45)
    .timeScale(SPEED);
}

function showToast() {
  toast.querySelector("span").textContent = toast.dataset.message;
  gsap.fromTo(toast, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.35, ease: "power3.out" }).timeScale(SPEED);
  hideLater?.kill();
  // 見せておく時間は、動きを減らす設定でも縮めない
  hideLater = gsap.delayedCall(3, () => {
    gsap.to(toast, { opacity: 0, y: 16, duration: 0.25, ease: "power2.in", onComplete: () => (toast.querySelector("span").textContent = "") }).timeScale(SPEED);
  });
}

document.querySelectorAll(".project__delete").forEach((button) => {
  button.addEventListener("click", () => open(button.closest(".project")));
});
input.addEventListener("input", update);
dialog.querySelectorAll("[data-confirm-cancel]").forEach((node) => node.addEventListener("click", () => close()));

// Enter でも「削除する」でも届く。押せない間は送らず、欄を震わせて理由を見せる
form.addEventListener("submit", (event) => {
  event.preventDefault();
  if (!matches()) {
    setHint("error");
    if (!reduce) gsap.fromTo(field, { x: 0 }, { x: SHAKE, duration: 0.36, ease: (p) => (1 - p) * Math.sin(p * Math.PI * 6) });
    input.focus();
    return;
  }
  close(fold.bind(null, row));
});

// Esc は既定だとその場で消えるので、止めて同じ動きで閉じる
dialog.addEventListener("cancel", (event) => {
  event.preventDefault();
  close();
});
// Esc を続けて押すと、動きを待たずに閉じることがある。残った値はここで戻す
dialog.addEventListener("close", () => gsap.set([form, scrim], { clearProps: "opacity,transform" }));

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

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