VNL Works
VNL Works
VNL Works
← Components
Download ZIPRegistry JSON
Package: tailwind-motion-kit
ui

Dialog / Drawer

Modal / Drawer / Bottom sheet を同じ作法で扱う。

modaldrawercss+js
Customize

トークン: --mk-dialog-radius / --mk-dialog-offset / --mk-dialog-backdrop-opacity

A11y

role="dialog" + aria-modal="true" を前提。ESCで閉じる。

Install

“壊れにくさ”優先のため、依存は増やさずに ファイルをコピーして使う 形式です。

Files

まずは下記ファイルをプロジェクトに配置してください。

required-files.md
- tailwind-motion-kit/css/00_tokens.css (css)
- tailwind-motion-kit/css/11_dialog.css (css)
- tailwind-motion-kit/js/dialog.js (js)
Option A

まずは Bundle を読み込んで、動作を確認するのがおすすめです。

globals.css
/* Bundle: まずはこれが一番簡単 */
@import "./tailwind-motion-kit/css/99_bundle.css";
Option B

必要な CSS だけ読み込みたい場合はこちら。

globals.css
/* 1) CSS: グローバルCSSに追記 */
@import "./tailwind-motion-kit/css/00_tokens.css";
@import "./tailwind-motion-kit/css/11_dialog.css";
JavaScript

動きやUI制御が必要な場合のみ、初期化を追加します。

init.js
import { initDialog } from "./tailwind-motion-kit/js/dialog.js";
initDialog();

Snippets

Copy & paste
usage.html
<button data-mk-dialog-open="#demoDialog">Open</button>
<div class="mk-dialog" id="demoDialog" data-mk-dialog>...</div>
usage.js
import { initDialog } from "./tailwind-motion-kit/js/dialog.js";
initDialog();

Files

この部品が参照する実ファイルです。

tailwind-motion-kit/css/00_tokens.css
css
tailwind-motion-kit/css/00_tokens.css
/*!
 * Tailwind Motion Kit (mk) - Tokens
 * --------------------------------
 * 目的: 速度・距離・イージング等の“モーション言語”を統一し、
 *       パーツを増やしても品位が崩れない状態を作ります。
 *
 * 使い方:
 *  - :root の変数を上書きするだけで全体の動きを一括調整できます。
 *  - 個別要素は style="" か data-mk-* 属性で上書きできます(JSがある場合)。
 */

:root {
  /* --- イージング(加減速) --- */
  --mk-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --mk-ease-in: cubic-bezier(0.32, 0, 0.67, 0);
  --mk-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  /* “少しだけ遊び”が欲しい時用(多用は上品さを損ねます) */
  --mk-ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);

  /* --- 時間 --- */
  --mk-duration-fast: 160ms;
  --mk-duration-medium: 280ms;
  --mk-duration-slow: 600ms;
  --mk-duration-slower: 900ms;

  /* コンポーネントが参照する“共通デフォルト” */
  --mk-duration: var(--mk-duration-medium);
  --mk-ease: var(--mk-ease-out);
  --mk-delay: 0ms;

  /* --- 移動量 --- */
  --mk-distance-1: 6px;
  --mk-distance-2: 12px;
  --mk-distance-3: 20px;
  --mk-distance: var(--mk-distance-2);

  /* --- ぼかし --- */
  --mk-blur-0: 0px;
  --mk-blur-1: 6px;
  --mk-blur-2: 10px;
  --mk-blur: var(--mk-blur-1);

  /* --- 影(浮遊感) --- */
  --mk-shadow-lift: 0 12px 30px rgba(0, 0, 0, 0.14);
  --mk-shadow-soft: 0 10px 22px rgba(0, 0, 0, 0.10);

  /* --- アンダーライン --- */
  --mk-underline-thickness: 2px;
  --mk-underline-offset: 0.2em;

  /* --- フォーカスリング --- */
  --mk-ring-color: rgba(59, 130, 246, 0.35);
  --mk-ring-size: 3px;

  /* --- スケルトン(ロード中) --- */
  --mk-skeleton-a: rgba(0, 0, 0, 0.06);
  --mk-skeleton-b: rgba(0, 0, 0, 0.12);
  --mk-skeleton-speed: 1250ms;

  /* --- スピナー --- */
  --mk-spinner-size: 1.2em;
  --mk-spinner-border: 2px;

  /* --- テキスト分割表示 --- */
  --mk-char-step: 25ms; /* 1文字ごとの遅延 */


  /* --- ダイアログ / ドロワー --- */
  --mk-dialog-duration: var(--mk-duration-slow);
  --mk-dialog-ease: var(--mk-ease-out);
  --mk-dialog-backdrop-opacity: 0.45;
  --mk-dialog-offset: var(--mk-distance-3);
  --mk-dialog-scale-from: 0.995;
  --mk-dialog-radius: 18px;

  /* --- 折りたたみ(アコーディオン) --- */
  --mk-collapse-duration: var(--mk-duration-medium);
  --mk-collapse-ease: var(--mk-ease);

  /* --- Toast / 通知 --- */
  --mk-toast-gap: 10px;
  --mk-toast-width: min(360px, calc(100vw - 24px));
  --mk-toast-duration: var(--mk-duration-medium);
  --mk-toast-ease: var(--mk-ease-out);

  /* --- スクロール進捗(トップバー) --- */
  --mk-scroll-progress-top: 0px;
  --mk-scroll-progress-height: 3px;
  --mk-scroll-progress-ease: linear;

  /* --- ナビ下線インジケータ --- */
  --mk-nav-underline-height: 2px;
  --mk-nav-underline-radius: 999px;
  --mk-nav-duration: var(--mk-duration-medium);
  --mk-nav-ease: var(--mk-ease-out);


  /* --- UI state(idle/loading/success/error) --- */
  --mk-state-hold-success: 900ms;
  --mk-state-hold-error: 1400ms;


  /* --- Tabs --- */
  --mk-tabs-gap: 10px;
  --mk-tabs-radius: 999px;
  --mk-tabs-duration: var(--mk-duration-medium);
  --mk-tabs-ease: var(--mk-ease-out);
  --mk-tabs-underline-height: 2px;
  --mk-tabs-underline-color: currentColor;

  /* --- Tooltip --- */
  --mk-tooltip-bg: rgba(0, 0, 0, 0.82);
  --mk-tooltip-fg: #fff;
  --mk-tooltip-radius: 10px;
  --mk-tooltip-padding: 10px 12px;
  --mk-tooltip-gap: 8px;
  --mk-tooltip-shadow: 0 18px 60px rgba(0,0,0,0.35);
  --mk-tooltip-max-width: 260px;

  /* --- Stepper --- */
  --mk-stepper-gap: 14px;
  --mk-step-size: 28px;
  --mk-step-line: rgba(255, 255, 255, 0.14);
  --mk-step-done: rgba(59, 130, 246, 1);
  --mk-step-current: rgba(255, 255, 255, 0.92);
  --mk-step-todo: rgba(255, 255, 255, 0.5);

}

/* motion減衰設定のあるユーザーには、動きを最小化して“結果”を出します */
@media (prefers-reduced-motion: reduce) {
  :root {
    --mk-duration-fast: 0ms;
    --mk-duration-medium: 0ms;
    --mk-duration-slow: 0ms;
    --mk-duration-slower: 0ms;
    --mk-duration: 0ms;
    --mk-delay: 0ms;
  }
}
tailwind-motion-kit/css/11_dialog.css
css
tailwind-motion-kit/css/11_dialog.css
/*!
 * Tailwind Motion Kit (mk) - Dialog / Drawer
 * -----------------------------------------
 * 目的: Modal / Drawer / Bottom sheet を “同じ作法” で扱えるようにします。
 *
 * 基本マークアップ(最小):
 * <div class="mk-dialog" id="demoDialog" data-mk-dialog>
 *   <div class="mk-dialog-backdrop" data-mk-dialog-close></div>
 *   <div class="mk-dialog-panel" role="dialog" aria-modal="true">
 *     ...
 *     <button data-mk-dialog-close>Close</button>
 *   </div>
 * </div>
 *
 * トリガー(任意):
 * <button data-mk-dialog-open="#demoDialog">Open</button>
 *
 * JS:
 *   import { initDialog } from "./js/dialog.js";
 *   initDialog();
 */

.mk-dialog {
  position: fixed;
  inset: 0;
  z-index: 60;

  display: grid;
  align-items: center;
  justify-items: center;
  padding: 16px;

  /* state */
  visibility: hidden;
  pointer-events: none;
}

/* 位置バリアント(root に付ける) */
.mk-dialog.mk-dialog-top    { align-items: start; }
.mk-dialog.mk-dialog-bottom { align-items: end; }
.mk-dialog.mk-dialog-left   { justify-items: start; }
.mk-dialog.mk-dialog-right  { justify-items: end; }

.mk-dialog[data-mk-state="open"],
.mk-dialog[data-mk-state="closing"] {
  visibility: visible;
}

.mk-dialog[data-mk-state="open"] {
  pointer-events: auto;
}

/* backdrop */
.mk-dialog-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, var(--mk-dialog-backdrop-opacity));
  opacity: 0;

  transition: opacity var(--mk-dialog-duration) var(--mk-dialog-ease);
}

/* panel */
.mk-dialog-panel {
  /* 位置とサイズは Tailwind 等で上書きする前提でも良いですが、最低限の“見た目”は用意します */
  width: min(720px, calc(100vw - 32px));
  max-height: calc(100vh - 32px);
  overflow: auto;

  background: var(--mk-dialog-bg, #fff);
  color: var(--mk-dialog-fg, inherit);
  border-radius: var(--mk-dialog-radius);
  box-shadow: var(--mk-shadow-lift);

  opacity: 0;
  --mk-dialog-from-x: 0px;
  --mk-dialog-from-y: var(--mk-dialog-offset);
  --mk-dialog-from-scale: var(--mk-dialog-scale-from);

  transform:
    translate3d(var(--mk-dialog-from-x), var(--mk-dialog-from-y), 0)
    scale(var(--mk-dialog-from-scale));
  will-change: transform, opacity;

  transition:
    transform var(--mk-dialog-duration) var(--mk-dialog-ease),
    opacity var(--mk-dialog-duration) var(--mk-dialog-ease);
}

/* open → 表示 */
.mk-dialog[data-mk-state="open"] .mk-dialog-backdrop { opacity: 1; }
.mk-dialog[data-mk-state="open"] .mk-dialog-panel {
  opacity: 1;
  transform: translate3d(0, 0, 0) scale(1);
}

/* drawer / sheet 用(panel に付ける) */
.mk-dialog-panel.mk-dialog-drawer {
  width: min(420px, calc(100vw - 24px));
  height: calc(100vh - 24px);
  max-height: none;
}

/* 右ドロワー: root に mk-dialog-right + panel に mk-dialog-drawer-right */
.mk-dialog-panel.mk-dialog-drawer-right {
  --mk-dialog-from-x: var(--mk-dialog-offset);
  --mk-dialog-from-y: 0px;
  --mk-dialog-from-scale: 1;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}

/* 左ドロワー */
.mk-dialog-panel.mk-dialog-drawer-left {
  --mk-dialog-from-x: calc(var(--mk-dialog-offset) * -1);
  --mk-dialog-from-y: 0px;
  --mk-dialog-from-scale: 1;
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
}

/* 下からのシート(bottom sheet): root に mk-dialog-bottom + panel に mk-dialog-sheet */
.mk-dialog-panel.mk-dialog-sheet {
  width: min(720px, calc(100vw - 24px));
  height: auto;
  max-height: calc(100vh - 24px);
  --mk-dialog-from-x: 0px;
  --mk-dialog-from-y: var(--mk-dialog-offset);
  --mk-dialog-from-scale: 1;

  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

/* reduce motion */
@media (prefers-reduced-motion: reduce) {
  .mk-dialog-backdrop,
  .mk-dialog-panel {
    transition: none !important;
  }
}
tailwind-motion-kit/js/dialog.js
js
tailwind-motion-kit/js/dialog.js
// Tailwind Motion Kit (mk) - Dialog / Drawer
// -----------------------------------------
// data-mk-dialog を持つ要素を “開閉できるUI部品” として扱います。
// - open trigger:  [data-mk-dialog-open="#id"]
// - close trigger: [data-mk-dialog-close](valueがあれば対象selector)
// - ESC で閉じる(open中)
// - Tab を dialog 内に閉じ込める(簡易 focus trap)

function prefersReducedMotion() {
  return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function parseTimeMs(v) {
  const s = String(v || "").trim();
  if (!s) return 0;
  if (s.endsWith("ms")) return Number(s.replace("ms", "")) || 0;
  if (s.endsWith("s")) return (Number(s.replace("s", "")) * 1000) || 0;
  const n = Number(s);
  return Number.isFinite(n) ? n : 0;
}

function maxTransitionMs(el) {
  if (!el) return 0;
  const cs = getComputedStyle(el);
  const ds = cs.transitionDuration.split(",").map((x) => parseTimeMs(x));
  const ls = cs.transitionDelay.split(",").map((x) => parseTimeMs(x));
  const n = Math.max(ds.length, ls.length, 1);
  let max = 0;
  for (let i = 0; i < n; i++) {
    const d = ds[i] ?? ds[ds.length - 1] ?? 0;
    const l = ls[i] ?? ls[ls.length - 1] ?? 0;
    max = Math.max(max, d + l);
  }
  return max;
}

function getFocusable(container) {
  if (!container) return [];
  const selectors = [
    "a[href]",
    "button:not([disabled])",
    "input:not([disabled])",
    "select:not([disabled])",
    "textarea:not([disabled])",
    "[tabindex]:not([tabindex='-1'])",
  ];
  return Array.from(container.querySelectorAll(selectors.join(",")))
    .filter((el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"));
}

let lockCount = 0;
let prevOverflow = "";

function lockScroll() {
  lockCount += 1;
  if (lockCount !== 1) return;
  const root = document.documentElement;
  prevOverflow = root.style.overflow;
  root.style.overflow = "hidden";
}

function unlockScroll() {
  lockCount = Math.max(0, lockCount - 1);
  if (lockCount !== 0) return;
  document.documentElement.style.overflow = prevOverflow;
}

function getPanel(dialogEl) {
  return dialogEl.querySelector(".mk-dialog-panel") || dialogEl;
}

function focusDialog(dialogEl) {
  const panel = getPanel(dialogEl);
  const auto = panel.querySelector("[data-mk-autofocus]");
  if (auto && typeof auto.focus === "function") {
    auto.focus();
    return;
  }
  const focusables = getFocusable(panel);
  if (focusables.length > 0) {
    focusables[0].focus();
  } else {
    // panel自身にフォーカスを当てられるようにする
    if (!panel.hasAttribute("tabindex")) panel.setAttribute("tabindex", "-1");
    panel.focus();
  }
}

function setState(dialogEl, state) {
  dialogEl.setAttribute("data-mk-state", state);
}

export function openDialog(target, { trigger } = {}) {
  const dialogEl = typeof target === "string" ? document.querySelector(target) : target;
  if (!dialogEl) return;

  // 既に open なら何もしない
  if (dialogEl.getAttribute("data-mk-state") === "open") return;

  // close others (simple policy)
  document.querySelectorAll("[data-mk-dialog][data-mk-state='open']").forEach((el) => {
    if (el !== dialogEl) closeDialog(el, { restoreFocus: false });
  });

  dialogEl.__mk_prevFocus = trigger || document.activeElement;

  const lock = dialogEl.getAttribute("data-mk-lock-scroll");
  if (lock !== "false") lockScroll();

  dialogEl.setAttribute("aria-hidden", "false");
  dialogEl.setAttribute("data-mk-open", "true");
  setState(dialogEl, "open");
  focusDialog(dialogEl);
}

export function closeDialog(target, { restoreFocus = true } = {}) {
  const dialogEl = typeof target === "string" ? document.querySelector(target) : target;
  if (!dialogEl) return;

  const state = dialogEl.getAttribute("data-mk-state");
  if (state === "closed" || state == null) {
    dialogEl.setAttribute("aria-hidden", "true");
    dialogEl.removeAttribute("data-mk-open");
    setState(dialogEl, "closed");
    return;
  }

  const lock = dialogEl.getAttribute("data-mk-lock-scroll");
  const reduce = prefersReducedMotion();

  const finalize = () => {
    dialogEl.setAttribute("aria-hidden", "true");
    dialogEl.removeAttribute("data-mk-open");
    setState(dialogEl, "closed");

    if (lock !== "false") unlockScroll();

    if (restoreFocus) {
      const prev = dialogEl.__mk_prevFocus;
      if (prev && typeof prev.focus === "function") prev.focus();
      dialogEl.__mk_prevFocus = null;
    }
  };

  if (reduce) {
    finalize();
    return;
  }

  // closing state(CSS側で open 以外は閉じ方向に遷移する)
  setState(dialogEl, "closing");

  const panel = getPanel(dialogEl);
  const backdrop = dialogEl.querySelector(".mk-dialog-backdrop");
  const t = Math.max(maxTransitionMs(panel), maxTransitionMs(backdrop), 80);

  window.setTimeout(finalize, t + 20);
}

function closestDialog(el) {
  return el.closest("[data-mk-dialog]");
}

function resolveDialogFromAttr(value) {
  if (!value) return null;
  // "#id" などの selector として扱う
  try {
    return document.querySelector(value);
  } catch {
    return document.getElementById(value.replace("#", "")) || null;
  }
}

export function initDialog({
  dialogSelector = "[data-mk-dialog]",
  openSelector = "[data-mk-dialog-open]",
  closeSelector = "[data-mk-dialog-close]",
} = {}) {
  // 初期化: 状態を整える
  document.querySelectorAll(dialogSelector).forEach((dlg) => {
    const open = dlg.getAttribute("data-mk-open") === "true" || dlg.getAttribute("data-mk-state") === "open";
    dlg.setAttribute("aria-hidden", open ? "false" : "true");
    setState(dlg, open ? "open" : "closed");
  });

  // click delegation
  const onClick = (e) => {
    const openEl = e.target.closest(openSelector);
    if (openEl) {
      const sel = openEl.getAttribute("data-mk-dialog-open");
      const dlg = resolveDialogFromAttr(sel);
      if (dlg) {
        e.preventDefault();
        openDialog(dlg, { trigger: openEl });
      }
      return;
    }

    const closeEl = e.target.closest(closeSelector);
    if (closeEl) {
      const value = closeEl.getAttribute("data-mk-dialog-close");
      const dlg = value ? resolveDialogFromAttr(value) : closestDialog(closeEl);
      if (dlg) {
        e.preventDefault();
        closeDialog(dlg);
      }
      return;
    }

    // backdrop click(classで判断)
    const backdrop = e.target.closest(".mk-dialog-backdrop");
    if (backdrop) {
      const dlg = closestDialog(backdrop);
      if (dlg && dlg.getAttribute("data-mk-backdrop-close") !== "false") {
        e.preventDefault();
        closeDialog(dlg);
      }
    }
  };

  const onKeyDown = (e) => {
    // ESC
    if (e.key === "Escape") {
      const dlg = document.querySelector(`${dialogSelector}[data-mk-state="open"]`);
      if (dlg) {
        e.preventDefault();
        closeDialog(dlg);
      }
      return;
    }

    // focus trap
    if (e.key === "Tab") {
      const dlg = document.querySelector(`${dialogSelector}[data-mk-state="open"]`);
      if (!dlg) return;

      const panel = getPanel(dlg);
      const focusables = getFocusable(panel);
      if (focusables.length === 0) {
        e.preventDefault();
        focusDialog(dlg);
        return;
      }

      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      const active = document.activeElement;

      if (!panel.contains(active)) {
        e.preventDefault();
        first.focus();
        return;
      }

      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    }
  };

  document.addEventListener("click", onClick);
  document.addEventListener("keydown", onKeyDown);

  return {
    destroy() {
      document.removeEventListener("click", onClick);
      document.removeEventListener("keydown", onKeyDown);
    },
  };
}
Preview
Tips
  • • デザイン側は CSS 変数(tokens)で一括調整できます。
  • • 動きは “短く・小さく・同じ癖” を優先すると品位が保てます。
  • • prefers-reduced-motion を必ず考慮してください。